From c737a37f71bbf8570ca8b7774f90d64057ad5a15 Mon Sep 17 00:00:00 2001 From: Public Source Publisher Date: Thu, 1 Oct 2026 01:38:08 +0200 Subject: [PATCH] Initial curated chimera source from a8ae5b1db07fb49926c94585e2718e5130330974 --- .dockerignore | 39 + .env.example | 4 + .gitignore | 127 + CONTRIBUTING.md | 5 + Dockerfile | 14 + LICENSE | 202 + LICENSE_DECISION.md | 5 + NOTICE | 8 + PUBLIC_SOURCE_EXPORT.md | 3 + PUBLIC_SOURCE_MANIFEST.json | 2651 +++++++++ README.md | 99 + SECURITY.md | 21 + THIRD_PARTY_NOTICES.md | 7 + UPSTREAM.md | 7 + build/icon.png | Bin 0 -> 353492 bytes compose.yaml | 28 + docs/assets/chimera-overview.jpg | Bin 0 -> 96072 bytes donation-preload.js | 17 + main.js | 3416 +++++++++++ package-lock.json | 4304 ++++++++++++++ package.json | 96 + preload.js | 403 ++ remote-play-worker/README.txt | 48 + remote-play-worker/Start worker.cmd | 10 + remote-play-worker/install.ps1 | 38 + remote-play-worker/uninstall.ps1 | 15 + remote-play-worker/worker.js | 606 ++ renderer/assets/chimera-crest.svg | 21 + renderer/assets/fonts/OFL-Inter.txt | 93 + renderer/assets/fonts/OFL-SpaceGrotesk.txt | 93 + renderer/bootstrap.js | 85 + renderer/donation.html | 252 + renderer/gif-encoder.js | 338 ++ renderer/index.html | 2607 ++++++++ renderer/manifest.webmanifest | 15 + renderer/modules/autoloader.js | 476 ++ renderer/modules/backpork-gen.js | 824 +++ renderer/modules/backporks.js | 624 ++ renderer/modules/backups.js | 161 + renderer/modules/bdjb-generator.js | 60 + renderer/modules/cheats.js | 496 ++ renderer/modules/compatibility-guidance.js | 28 + renderer/modules/connection.js | 268 + renderer/modules/dashboard-widgets.js | 317 + renderer/modules/database.js | 521 ++ renderer/modules/ecosystem.js | 1496 +++++ renderer/modules/elf-arsenal.js | 213 + renderer/modules/etahen-link-ux.js | 63 + renderer/modules/ftpbrowser.js | 115 + renderer/modules/game-source-scan.js | 23 + renderer/modules/games.js | 481 ++ renderer/modules/hash.js | 164 + renderer/modules/helpers.js | 199 + renderer/modules/hotkeys.js | 103 + renderer/modules/i18n.js | 84 + renderer/modules/import-wizard.js | 1 + renderer/modules/init.js | 114 + renderer/modules/install-destination.js | 77 + renderer/modules/library-model.js | 16 + renderer/modules/manage.js | 391 ++ renderer/modules/media.js | 820 +++ renderer/modules/modal.js | 1527 +++++ renderer/modules/navigation-routes.js | 110 + renderer/modules/navigation.js | 1071 ++++ renderer/modules/operations.js | 700 +++ renderer/modules/packages.js | 29 + renderer/modules/payload.js | 636 ++ renderer/modules/pfs-ripper.js | 160 + renderer/modules/premium-illustrations.js | 203 + renderer/modules/protection-model.js | 103 + renderer/modules/psnotify.js | 268 + renderer/modules/pwa.js | 5 + renderer/modules/savemgr.js | 561 ++ renderer/modules/settings.js | 861 +++ renderer/modules/state.js | 485 ++ renderer/modules/system-state.js | 76 + renderer/modules/system-view.js | 767 +++ renderer/modules/title-workspace.js | 219 + renderer/modules/transfers.js | 216 + renderer/modules/xavatar.js | 707 +++ renderer/modules/y2jb-generator.js | 191 + renderer/offline.html | 11 + renderer/pork-evidence-bridge.js | 1 + renderer/pork-web-shim.js | 1 + renderer/server-login.html | 32 + renderer/server-login.js | 48 + renderer/service-worker.js | 39 + renderer/splash.html | 84 + renderer/styles/base.css | 1 + renderer/styles/chimera-system.css | 442 ++ renderer/styles/components.css | 249 + renderer/styles/etahen-link.css | 14 + renderer/styles/features.css | 576 ++ renderer/styles/game-covers.css | 1 + renderer/styles/import-wizard.css | 12 + renderer/styles/install-destination.css | 1 + renderer/styles/mobile-companion.css | 1 + renderer/styles/navigation.css | 1 + renderer/styles/package-library.css | 6 + renderer/styles/pages.css | 603 ++ renderer/styles/premium.css | 13 + renderer/styles/protection-copies.css | 1 + renderer/styles/ps4-packages.css | 2 + renderer/styles/ps5-native-packages.css | 88 + renderer/styles/ps5-profiles.css | 12 + renderer/styles/server-login.css | 13 + renderer/styles/stitch.css | 1 + renderer/styles/title-workspace.css | 1 + renderer/styles/trophies-import.css | 11 + renderer/styles/trophies-installed.css | 1 + renderer/styles/trophies-library.css | 11 + renderer/styles/trophies-live.css | 1 + renderer/styles/trophies-progression.css | 4 + renderer/styles/trophies-unlock.css | 1 + renderer/styles/trophies.css | 1 + renderer/system-view.html | 424 ++ schemas/chimera-title-manifest.schema.json | 211 + schemas/compatibility-provider.schema.json | 158 + schemas/install-recipe.schema.json | 117 + .../ps4-package-backup-manifest.schema.json | 36 + .../ps5-native-package-manifest.schema.json | 110 + schemas/task-queue.schema.json | 162 + schemas/trophy-local-inventory.schema.json | 78 + schemas/trophy-psn-proof.schema.json | 184 + scripts/check-syntax.js | 62 + server.js | 192 + src/backpork-gen/decrypt-fself.js | 137 + src/backpork-gen/fakelib-manager.js | 281 + src/backpork-gen/make-fself.js | 217 + src/backpork-gen/processor-worker.js | 35 + src/backpork-gen/processor.js | 305 + src/backpork-gen/sdk-patcher.js | 122 + src/bdjb-generator.js | 72 + src/cheats.js | 71 + src/core/command-registry.js | 125 + src/database.js | 1374 +++++ .../migrations/0001-create-core-schema.js | 133 + .../migrations/0002-migrate-legacy-columns.js | 144 + .../migrations/0003-create-v3-core.js | 319 + .../0004-migrate-legacy-games-to-v3.js | 334 ++ ...0005-create-semantic-evidence-overrides.js | 10 + .../migrations/0006-add-storage-evidence.js | 24 + .../0007-add-storage-friendly-label.js | 10 + .../0008-create-guided-game-moves.js | 26 + .../migrations/0009-create-package-sets.js | 81 + .../migrations/0010-create-install-recipes.js | 45 + .../0011-create-compatibility-findings.js | 40 + .../0012-create-trophy-psn-snapshots.js | 37 + .../0013-create-trophy-local-snapshots.js | 38 + .../0014-create-trophy-account-mappings.js | 24 + ...0015-create-trophy-reconstruction-plans.js | 39 + ...6-create-trophy-reconstruction-journals.js | 56 + ...0017-create-ps5-system-backup-inventory.js | 29 + .../0018-create-save-recovery-plans.js | 26 + .../0019-add-v3-performance-indexes.js | 15 + .../0020-create-trophy-progression-plans.js | 29 + src/exfat/native-exfat.js | 293 + src/ftp.js | 845 +++ src/github.js | 307 + src/hashing.js | 32 + src/ipc/backpork-gen.js | 407 ++ src/ipc/bdjb-generator.js | 60 + src/ipc/conversion.js | 808 +++ src/ipc/elf-arsenal.js | 165 + src/ipc/garlic-saves.js | 7 + src/ipc/media.js | 838 +++ src/ipc/pfs-ripper.js | 148 + src/ipc/system-state.js | 63 + src/ipc/trophy-history.js | 91 + src/ipc/trophy-progression.js | 24 + src/ipc/y2jb-generator.js | 44 + src/persistence/settings-store.js | 67 + src/pfs/native-pfs.js | 644 ++ src/platform-capabilities.js | 21 + src/platform/build-info.js | 24 + src/platform/data-paths.js | 69 + src/platform/logger.js | 71 + src/prospero.js | 306 + src/renderer/recipe-editor.css | 1 + src/renderer/recipe-editor.js | 49 + src/renderer/trophies.js | 479 ++ src/security/passwords.js | 28 + src/security/read-only-sql.js | 130 + src/security/secrets-store.js | 60 + src/security/totp.js | 63 + src/server/app-server.js | 1091 ++++ src/server/create-registry.js | 2437 ++++++++ src/server/payload-manager-proxy.js | 256 + src/server/register-functional-modules.js | 1250 ++++ src/services/apr-ampr-adapter-service.js | 104 + src/services/archive-worker-service.js | 240 + src/services/audit-log-service.js | 146 + src/services/backup-replication-service.js | 331 ++ src/services/chimera-beacon-client.js | 174 + src/services/companion-worker-service.js | 101 + .../compatibility-guidance-service.js | 29 + .../compatibility-provider-service.js | 201 + .../console-capability-graph-service.js | 121 + src/services/console-profile-service.js | 287 + src/services/console-scan-reconciler.js | 498 ++ src/services/conversion-service.js | 266 + .../ecosystem-intelligence-service.js | 624 ++ src/services/ecosystem-service.js | 1564 +++++ src/services/elf-arsenal-client.js | 82 + .../etahen-dpi-v2-package-installer.js | 110 + src/services/etahen-link-agent.js | 222 + src/services/etahen-link-contract.js | 125 + src/services/etahen-link-inventory-service.js | 141 + src/services/etahen-link-lock.js | 89 + src/services/etahen-link-operations.js | 471 ++ src/services/etahen-link-orchestrator.js | 419 ++ src/services/etahen-link-shim-installer.js | 99 + src/services/ezremote-server-client.js | 192 + src/services/ftp-scan-root-coverage.js | 62 + src/services/game-metadata-service.js | 232 + src/services/game-metadata-view.js | 131 + src/services/game-move-adapter-service.js | 106 + src/services/game-update-service.js | 536 ++ src/services/guided-game-move-service.js | 143 + src/services/import-classifier-service.js | 192 + src/services/import-staging-service.js | 326 + src/services/import-upload-service.js | 273 + .../imported-trophy-definition-service.js | 89 + .../inactive-legacy-trophy-unlock-service.js | 217 + src/services/install-destination-service.js | 69 + src/services/install-orchestrator-service.js | 165 + src/services/install-recipe-repository.js | 47 + src/services/install-recipe-service.js | 136 + ...legacy-detached-trophy-mutation-adapter.js | 87 + ...egacy-detached-trophy-preflight-service.js | 226 + src/services/linux-listener-process.js | 424 ++ src/services/live-trophy-catalog-service.js | 221 + src/services/local-library-scanner.js | 699 +++ .../local-trophy-inventory-service.js | 222 + src/services/media-cache-service.js | 265 + src/services/media-import-service.js | 174 + src/services/notification-service.js | 72 + src/services/operations-service.js | 1053 ++++ src/services/patch-bundle-service.js | 123 + src/services/payload-catalog-service.js | 85 + ...payload-manager-autoload-repair-service.js | 508 ++ src/services/payload-runtime-service.js | 240 + src/services/pfs-conversion-worker.js | 18 + .../platform-artifact-staging-service.js | 199 + .../platform-control-center-service.js | 54 + src/services/platform-operation-service.js | 52 + src/services/portable-manifest-service.js | 197 + src/services/portrait-cover-provider.js | 203 + .../post-install-verification-service.js | 112 + src/services/ps4-package-backup-service.js | 177 + .../ps4-package-registration-service.js | 30 + src/services/ps4-pkg-builder-provider.js | 76 + src/services/ps4-pkg-installer-adapter.js | 103 + src/services/ps5-console-staging-service.js | 266 + src/services/ps5-content-adapter-service.js | 265 + src/services/ps5-fih-validator.js | 194 + .../ps5-fpkg-a8-control-qualification.js | 227 + src/services/ps5-fpkg-a8-experiment-ledger.js | 212 + .../ps5-fpkg-compatibility-profile-service.js | 228 + src/services/ps5-fpkg-completion-trace.js | 301 + src/services/ps5-fpkg-content-auth-trace.js | 258 + .../ps5-fpkg-control-candidate-service.js | 241 + .../ps5-fpkg-control-metadata-assessment.js | 146 + src/services/ps5-fpkg-control-taxonomy.js | 137 + .../ps5-fpkg-distinct-control-install-gate.js | 236 + ...5-fpkg-distinct-control-package-service.js | 274 + .../ps5-fpkg-kstuff-runtime-identity.js | 117 + src/services/ps5-fpkg-readiness-service.js | 227 + .../ps5-fpkg-route-selection-trace.js | 338 ++ src/services/ps5-klog-service.js | 324 + .../ps5-launch-reference-package-service.js | 89 + .../ps5-native-package-install-service.js | 597 ++ ...ps5-native-package-registration-service.js | 116 + src/services/ps5-native-package-service.js | 362 ++ .../ps5-native-pkg-builder-provider.js | 411 ++ src/services/ps5-p15-a10-runtime-profile.js | 28 + .../ps5-p15-a10-runtime-stage-service.js | 264 + src/services/ps5-p15-r1-profile.js | 190 + .../ps5-p15-r2-installed-content-verifier.js | 160 + .../ps5-p15-r2-qualification-service.js | 255 + src/services/ps5-p15-r2-qualification.js | 428 ++ src/services/ps5-p15-r3-host-artifact.js | 130 + .../ps5-package-comparison-service.js | 73 + src/services/ps5-payload-manager-client.js | 244 + .../ps5-profile-management-service.js | 135 + .../ps5-shellui-launch-observation.js | 176 + .../ps5-shellui-transition-observation.js | 329 ++ .../ps5-upstream-control-package-service.js | 81 + src/services/ps5debug-readonly-client.js | 140 + .../ps5upload-package-installer-provider.js | 771 +++ src/services/ps5upload-profile-provider.js | 109 + src/services/ps5upload-runtime-identity.js | 126 + src/services/ps5upload-runtime-service.js | 652 ++ .../psn-trophy-history-import-service.js | 205 + .../remote-play-intelligence-service.js | 50 + src/services/remote-play-service.js | 493 ++ src/services/save-recovery-plan-service.js | 105 + src/services/semantic-evidence-service.js | 50 + .../shadps4-launch-adapter-service.js | 100 + src/services/shadps4-library-service.js | 149 + src/services/source-classification-service.js | 73 + src/services/storage-inventory-service.js | 277 + .../system-backup-recovery-lab-service.js | 111 + src/services/transfer-service.js | 440 ++ src/services/trophy-mapping-diff-service.js | 178 + .../trophy-progression-planner-service.js | 239 + .../trophy-reconstruction-action-service.js | 234 + .../trophy-reconstruction-plan-service.js | 161 + src/services/trophy-snapshot-service.js | 292 + src/services/unified-autoloader-service.js | 176 + src/services/update-lifecycle-service.js | 180 + .../upstream-binding-registry-service.js | 176 + src/services/v3-derived-views.js | 191 + src/services/v3-library-service.js | 264 + src/services/v3-repositories.js | 528 ++ src/services/websrv-discovery-service.js | 55 + src/system-state-client.js | 24 + .../UFS2imageexfatTools/New-OsfExfatImage.ps1 | 399 ++ .../UFS2imageexfatTools/make_image.bat | 53 + src/ufs2ElectronApp/main.js | 968 +++ src/ufs2ElectronApp/package-lock.json | 5248 +++++++++++++++++ src/ufs2ElectronApp/package.json | 63 + src/ufs2ElectronApp/preload.js | 58 + src/ufs2ElectronApp/renderer/app.js | 1005 ++++ src/ufs2ElectronApp/renderer/index.html | 325 + src/ufs2ElectronApp/renderer/style.css | 284 + src/ufs2ElectronModule/README.md | 246 + src/ufs2ElectronModule/index.js | 257 + src/ufs2ElectronModule/package.json | 38 + src/ufs2ElectronModule/preload.js | 246 + .../renderer/ufs2Renderer.js | 124 + src/ufs2ElectronModule/src/ipcHandlers.js | 327 + src/ufs2ElectronModule/src/ufs2Core.js | 905 +++ src/ufs2ElectronModule/test-smoke.js | 21 + src/ufs2ElectronModule/test.bat | 142 + src/ufs2ElectronModule/test.js | 281 + src/web-server.js | 399 ++ src/xavatarElectronModule/README.md | 237 + .../examples/basic-usage.js | 62 + .../examples/electron-integration/index.html | 216 + .../examples/electron-integration/main.js | 59 + .../electron-integration/package.json | 16 + .../examples/test-avatar.xavatar | Bin 0 -> 3358 bytes src/xavatarElectronModule/index.js | 177 + src/xavatarElectronModule/package-lock.json | 1491 +++++ src/xavatarElectronModule/package.json | 42 + src/xavatarElectronModule/preload.js | 154 + .../renderer/xavatarRenderer.js | 303 + src/xavatarElectronModule/src/dxt5Encoder.js | 260 + src/xavatarElectronModule/src/ipcHandlers.js | 121 + src/xavatarElectronModule/src/xavatarCore.js | 310 + src/xavatarElectronModule/test/basic.js | 32 + src/y2jb-generator.js | 229 + .../build-kstuff-diagnostic-loader.test.mjs | 61 + .../autonomous/inspect-ps5-fih-route.test.mjs | 87 + .../parse-kstuff-mailbox-capture.test.mjs | 63 + test/baseline-fixture-harness.test.js | 44 + test/bdjb-generator.test.js | 39 + test/fixtures/ch-1204-etahen-link-ui.html | 21 + test/fixtures/chimera-baseline-fixtures.json | 113 + .../ps5-data-dlc/data/owned-content.txt | 1 + test/fixtures/ps5-data-dlc/sce_sys/param.json | 13 + test/navigation.test.js | 312 + test/platform-capabilities.test.js | 22 + test/server/app-server.test.js | 340 ++ test/server/audit-log-service.test.js | 53 + .../server/backup-replication-service.test.js | 81 + test/server/build-info.test.js | 41 + .../ch-0003-registry-queue-completion.test.js | 409 ++ test/server/ch-0107-library-contract.test.js | 73 + .../server/ch-0108-legacy-write-guard.test.js | 21 + test/server/ch-0109-migration-gate.test.js | 26 + test/server/ch-0201-title-workspace.test.js | 64 + test/server/ch-0202-protection-policy.test.js | 56 + test/server/ch-0203-storage-inventory.test.js | 80 + .../ch-0204-install-destination.test.js | 75 + test/server/ch-0205-drive-locator.test.js | 58 + test/server/ch-0206-guided-move.test.js | 62 + test/server/ch-0207-game-move-adapter.test.js | 81 + .../server/ch-0301-import-upload-http.test.js | 50 + .../ch-0301-import-upload-service.test.js | 73 + test/server/ch-0302-import-staging.test.js | 94 + test/server/ch-0303-archive-worker.test.js | 101 + test/server/ch-0304-import-classifier.test.js | 100 + test/server/ch-0305-portable-manifest.test.js | 69 + test/server/ch-0306-import-wizard.test.js | 63 + .../ch-0401-package-set-persistence.test.js | 74 + .../ch-0402-install-recipe-validation.test.js | 60 + test/server/ch-0403-recipe-editor.test.js | 45 + .../ch-0404-install-orchestrator.test.js | 78 + .../ch-0405-ps4-pkg-installer-adapter.test.js | 62 + .../ch-0406-ps5-content-adapters.test.js | 87 + .../ch-0407-post-install-verification.test.js | 107 + .../ch-0501-game-update-evidence.test.js | 64 + test/server/ch-0502-update-lifecycle.test.js | 82 + test/server/ch-0504-package-library.test.js | 55 + .../ch-0601-compatibility-provider.test.js | 73 + .../ch-0602-compatibility-guidance.test.js | 49 + test/server/ch-0603-patch-bundle.test.js | 60 + test/server/ch-0604-apr-ampr-adapter.test.js | 52 + test/server/ch-0701-media-cache.test.js | 76 + test/server/ch-0702-media-import.test.js | 109 + test/server/ch-0703-expert-navigation.test.js | 47 + test/server/ch-0704-settings-identity.test.js | 51 + test/server/ch-0705-p7-gate.test.js | 29 + test/server/ch-0901-trophy-snapshots.test.js | 131 + .../ch-0902-local-trophy-inventory.test.js | 104 + .../ch-0903-trophy-mapping-diff.test.js | 122 + ...ch-0904-trophy-reconstruction-plan.test.js | 107 + test/server/ch-0905-trophy-action.test.js | 198 + test/server/ch-0906-trophy-ui.test.js | 135 + ...ch-0907-system-backup-recovery-lab.test.js | 84 + .../server/ch-0908-save-recovery-plan.test.js | 72 + test/server/ch-0909-p9-gate.test.js | 63 + .../ch-0910-live-trophy-catalog.test.js | 181 + ...h-0911-imported-trophy-definitions.test.js | 45 + ...1-legacy-detached-trophy-preflight.test.js | 124 + ...0912-inactive-legacy-trophy-unlock.test.js | 154 + ...y-detached-trophy-mutation-adapter.test.js | 93 + .../server/ch-0913-psn-history-import.test.js | 75 + .../ch-0914-trophy-history-database.test.js | 23 + .../ch-0915-trophy-browser-auth.test.js | 51 + test/server/ch-1001-v3-performance.test.js | 47 + .../ch-1002-migration-rehearsal.test.js | 302 + .../server/ch-1101-ps4-package-backup.test.js | 165 + .../ch-1102-ps5-native-packages.test.js | 542 ++ ...h-1103-trophy-progression-database.test.js | 27 + ...103-trophy-progression-integration.test.js | 54 + ...ch-1103-trophy-progression-planner.test.js | 99 + .../ch-1104-ps5-console-staging.test.js | 207 + .../ch-1105-ps5-profile-management.test.js | 160 + .../ch-1106-linux-listener-process.test.js | 311 + test/server/ch-1106-ps5upload-runtime.test.js | 748 +++ .../server/ch-1107-ps5-fpkg-installer.test.js | 545 ++ .../ch-1108-ps5-package-comparison.test.js | 94 + ...-1109-ps5-upstream-control-package.test.js | 98 + ...-1110-ps5-launch-reference-package.test.js | 105 + .../ch-1111-etahen-dpi-v2-installer.test.js | 83 + .../ch-1201-etahen-link-contract.test.js | 57 + test/server/ch-1202-etahen-link-agent.test.js | 100 + .../ch-1203-etahen-link-inventory.test.js | 86 + test/server/ch-1204-etahen-link-ux.test.js | 43 + .../ch-1205-etahen-shim-installer.test.js | 52 + .../ch-1206-etahen-link-orchestrator.test.js | 144 + .../ch-1207-etahen-link-operations.test.js | 616 ++ .../ch-1301-clean-ps5-fpkg-builder.test.js | 199 + .../server/ch-1302-ps5-fpkg-readiness.test.js | 143 + .../server/ch-1303-ps5-fpkg-artifacts.test.js | 109 + test/server/ch-1304-ps5-fpkg-launch.test.js | 489 ++ test/server/ch-1305-ps5-fpkg-ux.test.js | 112 + .../ch-1306-ps5-fpkg-hardware-gate.test.js | 97 + ...ch-1401-autonomous-dependency-gate.test.js | 41 + .../ch-1401-p15-a10-product-lane.test.js | 341 ++ ...ch-1401-p15-a10-shellui-transition.test.js | 193 + ...1-p15-a11-retained-launch-evidence.test.js | 209 + .../ch-1401-p15-a7-active-preflight.test.js | 90 + ...1401-p15-a7-route-selection-source.test.js | 93 + ...-1401-p15-a7-route-selection-stage.test.js | 173 + ...-1401-p15-a7-route-selection-trace.test.js | 251 + .../ch-1401-p15-a8-completion-source.test.js | 108 + .../ch-1401-p15-a8-completion-stage.test.js | 179 + .../ch-1401-p15-a8-completion-trace.test.js | 183 + .../ch-1401-p15-a8-control-candidates.test.js | 139 + .../ch-1401-p15-a8-control-metadata.test.js | 122 + ...-1401-p15-a8-control-qualification.test.js | 197 + ...5-a8-distinct-control-install-gate.test.js | 220 + ...01-p15-a8-distinct-control-package.test.js | 261 + .../ch-1401-p15-a8-experiment-ledger.test.js | 125 + .../ch-1401-p15-r1-control-taxonomy.test.js | 77 + test/server/ch-1401-p15-r1-profile.test.js | 111 + ...-p15-r2-installed-content-verifier.test.js | 91 + .../ch-1401-p15-r2-product-lane.test.js | 78 + ...-1401-p15-r2-qualification-service.test.js | 158 + .../ch-1401-p15-r2-qualification.test.js | 185 + .../ch-1401-p15-r3-host-artifact.test.js | 87 + ...h-1401-ps5-fpkg-content-auth-trace.test.js | 183 + ...1-ps5-fpkg-kstuff-runtime-identity.test.js | 161 + ...401-ps5-fpkg-runtime-qualification.test.js | 645 ++ ...h-1401-ps5-fpkg-safe-runtime-stage.test.js | 107 + ...01-ps5-fpkg-semantic-reader-source.test.js | 45 + ...401-ps5-fpkg-semantic-reader-stage.test.js | 60 + ...ch-1401-ps5upload-runtime-identity.test.js | 100 + test/server/chimera-beacon-client.test.js | 84 + test/server/command-registry.test.js | 48 + test/server/companion-worker-service.test.js | 47 + test/server/console-profile-service.test.js | 71 + test/server/console-scan-reconciler.test.js | 345 ++ test/server/conversion-service.test.js | 56 + test/server/database-migrations.test.js | 496 ++ .../dependency-security-regressions.test.js | 35 + test/server/docker-runtime.test.js | 74 + .../ecosystem-intelligence-service.test.js | 315 + test/server/ecosystem-service.test.js | 320 + test/server/elf-arsenal-client.test.js | 32 + test/server/ezremote-server-client.test.js | 87 + test/server/ftp-list-dir.test.js | 32 + test/server/ftp-scan-root-coverage.test.js | 87 + test/server/functional-modules.test.js | 256 + test/server/game-metadata-view.test.js | 56 + test/server/game-metadata.test.js | 253 + test/server/game-update-service.test.js | 145 + test/server/github-security.test.js | 23 + test/server/headless-database.test.js | 137 + test/server/local-library-scanner.test.js | 251 + test/server/notification-service.test.js | 53 + test/server/operations-service.test.js | 270 + ...ad-manager-autoload-repair-service.test.js | 295 + test/server/payload-manager-proxy.test.js | 33 + test/server/payload-runtime-service.test.js | 74 + test/server/ps5-klog-service.test.js | 117 + .../server/ps5-payload-manager-client.test.js | 101 + test/server/ps5debug-readonly-client.test.js | 59 + test/server/public-readiness-security.test.js | 81 + test/server/pwa-security.test.js | 19 + test/server/read-only-sql.test.js | 27 + test/server/remote-play-service.test.js | 216 + test/server/remote-play-worker.test.js | 67 + test/server/renderer-security.test.js | 154 + test/server/security.test.js | 42 + test/server/server-login-client.test.js | 15 + .../source-classification-service.test.js | 45 + test/server/source-scan-route.test.js | 15 + test/server/title-workspace-a11y.test.js | 16 + test/server/totp.test.js | 26 + test/server/transfer-service.test.js | 107 + test/server/v3-derived-views.test.js | 320 + test/server/v3-repositories.test.js | 262 + test/server/web-command-contract.test.js | 19 + test/server/websocket-backpressure.test.js | 18 + test/system-state-client.test.js | 13 + test/y2jb-generator.test.js | 54 + 531 files changed, 119270 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 LICENSE_DECISION.md create mode 100644 NOTICE create mode 100644 PUBLIC_SOURCE_EXPORT.md create mode 100644 PUBLIC_SOURCE_MANIFEST.json create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 THIRD_PARTY_NOTICES.md create mode 100644 UPSTREAM.md create mode 100644 build/icon.png create mode 100644 compose.yaml create mode 100644 docs/assets/chimera-overview.jpg create mode 100644 donation-preload.js create mode 100644 main.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 preload.js create mode 100644 remote-play-worker/README.txt create mode 100644 remote-play-worker/Start worker.cmd create mode 100644 remote-play-worker/install.ps1 create mode 100644 remote-play-worker/uninstall.ps1 create mode 100644 remote-play-worker/worker.js create mode 100644 renderer/assets/chimera-crest.svg create mode 100644 renderer/assets/fonts/OFL-Inter.txt create mode 100644 renderer/assets/fonts/OFL-SpaceGrotesk.txt create mode 100644 renderer/bootstrap.js create mode 100644 renderer/donation.html create mode 100644 renderer/gif-encoder.js create mode 100644 renderer/index.html create mode 100644 renderer/manifest.webmanifest create mode 100644 renderer/modules/autoloader.js create mode 100644 renderer/modules/backpork-gen.js create mode 100644 renderer/modules/backporks.js create mode 100644 renderer/modules/backups.js create mode 100644 renderer/modules/bdjb-generator.js create mode 100644 renderer/modules/cheats.js create mode 100644 renderer/modules/compatibility-guidance.js create mode 100644 renderer/modules/connection.js create mode 100644 renderer/modules/dashboard-widgets.js create mode 100644 renderer/modules/database.js create mode 100644 renderer/modules/ecosystem.js create mode 100644 renderer/modules/elf-arsenal.js create mode 100644 renderer/modules/etahen-link-ux.js create mode 100644 renderer/modules/ftpbrowser.js create mode 100644 renderer/modules/game-source-scan.js create mode 100644 renderer/modules/games.js create mode 100644 renderer/modules/hash.js create mode 100644 renderer/modules/helpers.js create mode 100644 renderer/modules/hotkeys.js create mode 100644 renderer/modules/i18n.js create mode 100644 renderer/modules/import-wizard.js create mode 100644 renderer/modules/init.js create mode 100644 renderer/modules/install-destination.js create mode 100644 renderer/modules/library-model.js create mode 100644 renderer/modules/manage.js create mode 100644 renderer/modules/media.js create mode 100644 renderer/modules/modal.js create mode 100644 renderer/modules/navigation-routes.js create mode 100644 renderer/modules/navigation.js create mode 100644 renderer/modules/operations.js create mode 100644 renderer/modules/packages.js create mode 100644 renderer/modules/payload.js create mode 100644 renderer/modules/pfs-ripper.js create mode 100644 renderer/modules/premium-illustrations.js create mode 100644 renderer/modules/protection-model.js create mode 100644 renderer/modules/psnotify.js create mode 100644 renderer/modules/pwa.js create mode 100644 renderer/modules/savemgr.js create mode 100644 renderer/modules/settings.js create mode 100644 renderer/modules/state.js create mode 100644 renderer/modules/system-state.js create mode 100644 renderer/modules/system-view.js create mode 100644 renderer/modules/title-workspace.js create mode 100644 renderer/modules/transfers.js create mode 100644 renderer/modules/xavatar.js create mode 100644 renderer/modules/y2jb-generator.js create mode 100644 renderer/offline.html create mode 100644 renderer/pork-evidence-bridge.js create mode 100644 renderer/pork-web-shim.js create mode 100644 renderer/server-login.html create mode 100644 renderer/server-login.js create mode 100644 renderer/service-worker.js create mode 100644 renderer/splash.html create mode 100644 renderer/styles/base.css create mode 100644 renderer/styles/chimera-system.css create mode 100644 renderer/styles/components.css create mode 100644 renderer/styles/etahen-link.css create mode 100644 renderer/styles/features.css create mode 100644 renderer/styles/game-covers.css create mode 100644 renderer/styles/import-wizard.css create mode 100644 renderer/styles/install-destination.css create mode 100644 renderer/styles/mobile-companion.css create mode 100644 renderer/styles/navigation.css create mode 100644 renderer/styles/package-library.css create mode 100644 renderer/styles/pages.css create mode 100644 renderer/styles/premium.css create mode 100644 renderer/styles/protection-copies.css create mode 100644 renderer/styles/ps4-packages.css create mode 100644 renderer/styles/ps5-native-packages.css create mode 100644 renderer/styles/ps5-profiles.css create mode 100644 renderer/styles/server-login.css create mode 100644 renderer/styles/stitch.css create mode 100644 renderer/styles/title-workspace.css create mode 100644 renderer/styles/trophies-import.css create mode 100644 renderer/styles/trophies-installed.css create mode 100644 renderer/styles/trophies-library.css create mode 100644 renderer/styles/trophies-live.css create mode 100644 renderer/styles/trophies-progression.css create mode 100644 renderer/styles/trophies-unlock.css create mode 100644 renderer/styles/trophies.css create mode 100644 renderer/system-view.html create mode 100644 schemas/chimera-title-manifest.schema.json create mode 100644 schemas/compatibility-provider.schema.json create mode 100644 schemas/install-recipe.schema.json create mode 100644 schemas/ps4-package-backup-manifest.schema.json create mode 100644 schemas/ps5-native-package-manifest.schema.json create mode 100644 schemas/task-queue.schema.json create mode 100644 schemas/trophy-local-inventory.schema.json create mode 100644 schemas/trophy-psn-proof.schema.json create mode 100644 scripts/check-syntax.js create mode 100644 server.js create mode 100644 src/backpork-gen/decrypt-fself.js create mode 100644 src/backpork-gen/fakelib-manager.js create mode 100644 src/backpork-gen/make-fself.js create mode 100644 src/backpork-gen/processor-worker.js create mode 100644 src/backpork-gen/processor.js create mode 100644 src/backpork-gen/sdk-patcher.js create mode 100644 src/bdjb-generator.js create mode 100644 src/cheats.js create mode 100644 src/core/command-registry.js create mode 100644 src/database.js create mode 100644 src/database/migrations/0001-create-core-schema.js create mode 100644 src/database/migrations/0002-migrate-legacy-columns.js create mode 100644 src/database/migrations/0003-create-v3-core.js create mode 100644 src/database/migrations/0004-migrate-legacy-games-to-v3.js create mode 100644 src/database/migrations/0005-create-semantic-evidence-overrides.js create mode 100644 src/database/migrations/0006-add-storage-evidence.js create mode 100644 src/database/migrations/0007-add-storage-friendly-label.js create mode 100644 src/database/migrations/0008-create-guided-game-moves.js create mode 100644 src/database/migrations/0009-create-package-sets.js create mode 100644 src/database/migrations/0010-create-install-recipes.js create mode 100644 src/database/migrations/0011-create-compatibility-findings.js create mode 100644 src/database/migrations/0012-create-trophy-psn-snapshots.js create mode 100644 src/database/migrations/0013-create-trophy-local-snapshots.js create mode 100644 src/database/migrations/0014-create-trophy-account-mappings.js create mode 100644 src/database/migrations/0015-create-trophy-reconstruction-plans.js create mode 100644 src/database/migrations/0016-create-trophy-reconstruction-journals.js create mode 100644 src/database/migrations/0017-create-ps5-system-backup-inventory.js create mode 100644 src/database/migrations/0018-create-save-recovery-plans.js create mode 100644 src/database/migrations/0019-add-v3-performance-indexes.js create mode 100644 src/database/migrations/0020-create-trophy-progression-plans.js create mode 100644 src/exfat/native-exfat.js create mode 100644 src/ftp.js create mode 100644 src/github.js create mode 100644 src/hashing.js create mode 100644 src/ipc/backpork-gen.js create mode 100644 src/ipc/bdjb-generator.js create mode 100644 src/ipc/conversion.js create mode 100644 src/ipc/elf-arsenal.js create mode 100644 src/ipc/garlic-saves.js create mode 100644 src/ipc/media.js create mode 100644 src/ipc/pfs-ripper.js create mode 100644 src/ipc/system-state.js create mode 100644 src/ipc/trophy-history.js create mode 100644 src/ipc/trophy-progression.js create mode 100644 src/ipc/y2jb-generator.js create mode 100644 src/persistence/settings-store.js create mode 100644 src/pfs/native-pfs.js create mode 100644 src/platform-capabilities.js create mode 100644 src/platform/build-info.js create mode 100644 src/platform/data-paths.js create mode 100644 src/platform/logger.js create mode 100644 src/prospero.js create mode 100644 src/renderer/recipe-editor.css create mode 100644 src/renderer/recipe-editor.js create mode 100644 src/renderer/trophies.js create mode 100644 src/security/passwords.js create mode 100644 src/security/read-only-sql.js create mode 100644 src/security/secrets-store.js create mode 100644 src/security/totp.js create mode 100644 src/server/app-server.js create mode 100644 src/server/create-registry.js create mode 100644 src/server/payload-manager-proxy.js create mode 100644 src/server/register-functional-modules.js create mode 100644 src/services/apr-ampr-adapter-service.js create mode 100644 src/services/archive-worker-service.js create mode 100644 src/services/audit-log-service.js create mode 100644 src/services/backup-replication-service.js create mode 100644 src/services/chimera-beacon-client.js create mode 100644 src/services/companion-worker-service.js create mode 100644 src/services/compatibility-guidance-service.js create mode 100644 src/services/compatibility-provider-service.js create mode 100644 src/services/console-capability-graph-service.js create mode 100644 src/services/console-profile-service.js create mode 100644 src/services/console-scan-reconciler.js create mode 100644 src/services/conversion-service.js create mode 100644 src/services/ecosystem-intelligence-service.js create mode 100644 src/services/ecosystem-service.js create mode 100644 src/services/elf-arsenal-client.js create mode 100644 src/services/etahen-dpi-v2-package-installer.js create mode 100644 src/services/etahen-link-agent.js create mode 100644 src/services/etahen-link-contract.js create mode 100644 src/services/etahen-link-inventory-service.js create mode 100644 src/services/etahen-link-lock.js create mode 100644 src/services/etahen-link-operations.js create mode 100644 src/services/etahen-link-orchestrator.js create mode 100644 src/services/etahen-link-shim-installer.js create mode 100644 src/services/ezremote-server-client.js create mode 100644 src/services/ftp-scan-root-coverage.js create mode 100644 src/services/game-metadata-service.js create mode 100644 src/services/game-metadata-view.js create mode 100644 src/services/game-move-adapter-service.js create mode 100644 src/services/game-update-service.js create mode 100644 src/services/guided-game-move-service.js create mode 100644 src/services/import-classifier-service.js create mode 100644 src/services/import-staging-service.js create mode 100644 src/services/import-upload-service.js create mode 100644 src/services/imported-trophy-definition-service.js create mode 100644 src/services/inactive-legacy-trophy-unlock-service.js create mode 100644 src/services/install-destination-service.js create mode 100644 src/services/install-orchestrator-service.js create mode 100644 src/services/install-recipe-repository.js create mode 100644 src/services/install-recipe-service.js create mode 100644 src/services/legacy-detached-trophy-mutation-adapter.js create mode 100644 src/services/legacy-detached-trophy-preflight-service.js create mode 100644 src/services/linux-listener-process.js create mode 100644 src/services/live-trophy-catalog-service.js create mode 100644 src/services/local-library-scanner.js create mode 100644 src/services/local-trophy-inventory-service.js create mode 100644 src/services/media-cache-service.js create mode 100644 src/services/media-import-service.js create mode 100644 src/services/notification-service.js create mode 100644 src/services/operations-service.js create mode 100644 src/services/patch-bundle-service.js create mode 100644 src/services/payload-catalog-service.js create mode 100644 src/services/payload-manager-autoload-repair-service.js create mode 100644 src/services/payload-runtime-service.js create mode 100644 src/services/pfs-conversion-worker.js create mode 100644 src/services/platform-artifact-staging-service.js create mode 100644 src/services/platform-control-center-service.js create mode 100644 src/services/platform-operation-service.js create mode 100644 src/services/portable-manifest-service.js create mode 100644 src/services/portrait-cover-provider.js create mode 100644 src/services/post-install-verification-service.js create mode 100644 src/services/ps4-package-backup-service.js create mode 100644 src/services/ps4-package-registration-service.js create mode 100644 src/services/ps4-pkg-builder-provider.js create mode 100644 src/services/ps4-pkg-installer-adapter.js create mode 100644 src/services/ps5-console-staging-service.js create mode 100644 src/services/ps5-content-adapter-service.js create mode 100644 src/services/ps5-fih-validator.js create mode 100644 src/services/ps5-fpkg-a8-control-qualification.js create mode 100644 src/services/ps5-fpkg-a8-experiment-ledger.js create mode 100644 src/services/ps5-fpkg-compatibility-profile-service.js create mode 100644 src/services/ps5-fpkg-completion-trace.js create mode 100644 src/services/ps5-fpkg-content-auth-trace.js create mode 100644 src/services/ps5-fpkg-control-candidate-service.js create mode 100644 src/services/ps5-fpkg-control-metadata-assessment.js create mode 100644 src/services/ps5-fpkg-control-taxonomy.js create mode 100644 src/services/ps5-fpkg-distinct-control-install-gate.js create mode 100644 src/services/ps5-fpkg-distinct-control-package-service.js create mode 100644 src/services/ps5-fpkg-kstuff-runtime-identity.js create mode 100644 src/services/ps5-fpkg-readiness-service.js create mode 100644 src/services/ps5-fpkg-route-selection-trace.js create mode 100644 src/services/ps5-klog-service.js create mode 100644 src/services/ps5-launch-reference-package-service.js create mode 100644 src/services/ps5-native-package-install-service.js create mode 100644 src/services/ps5-native-package-registration-service.js create mode 100644 src/services/ps5-native-package-service.js create mode 100644 src/services/ps5-native-pkg-builder-provider.js create mode 100644 src/services/ps5-p15-a10-runtime-profile.js create mode 100644 src/services/ps5-p15-a10-runtime-stage-service.js create mode 100644 src/services/ps5-p15-r1-profile.js create mode 100644 src/services/ps5-p15-r2-installed-content-verifier.js create mode 100644 src/services/ps5-p15-r2-qualification-service.js create mode 100644 src/services/ps5-p15-r2-qualification.js create mode 100644 src/services/ps5-p15-r3-host-artifact.js create mode 100644 src/services/ps5-package-comparison-service.js create mode 100644 src/services/ps5-payload-manager-client.js create mode 100644 src/services/ps5-profile-management-service.js create mode 100644 src/services/ps5-shellui-launch-observation.js create mode 100644 src/services/ps5-shellui-transition-observation.js create mode 100644 src/services/ps5-upstream-control-package-service.js create mode 100644 src/services/ps5debug-readonly-client.js create mode 100644 src/services/ps5upload-package-installer-provider.js create mode 100644 src/services/ps5upload-profile-provider.js create mode 100644 src/services/ps5upload-runtime-identity.js create mode 100644 src/services/ps5upload-runtime-service.js create mode 100644 src/services/psn-trophy-history-import-service.js create mode 100644 src/services/remote-play-intelligence-service.js create mode 100644 src/services/remote-play-service.js create mode 100644 src/services/save-recovery-plan-service.js create mode 100644 src/services/semantic-evidence-service.js create mode 100644 src/services/shadps4-launch-adapter-service.js create mode 100644 src/services/shadps4-library-service.js create mode 100644 src/services/source-classification-service.js create mode 100644 src/services/storage-inventory-service.js create mode 100644 src/services/system-backup-recovery-lab-service.js create mode 100644 src/services/transfer-service.js create mode 100644 src/services/trophy-mapping-diff-service.js create mode 100644 src/services/trophy-progression-planner-service.js create mode 100644 src/services/trophy-reconstruction-action-service.js create mode 100644 src/services/trophy-reconstruction-plan-service.js create mode 100644 src/services/trophy-snapshot-service.js create mode 100644 src/services/unified-autoloader-service.js create mode 100644 src/services/update-lifecycle-service.js create mode 100644 src/services/upstream-binding-registry-service.js create mode 100644 src/services/v3-derived-views.js create mode 100644 src/services/v3-library-service.js create mode 100644 src/services/v3-repositories.js create mode 100644 src/services/websrv-discovery-service.js create mode 100644 src/system-state-client.js create mode 100644 src/ufs2ElectronApp/UFS2imageexfatTools/New-OsfExfatImage.ps1 create mode 100644 src/ufs2ElectronApp/UFS2imageexfatTools/make_image.bat create mode 100644 src/ufs2ElectronApp/main.js create mode 100644 src/ufs2ElectronApp/package-lock.json create mode 100644 src/ufs2ElectronApp/package.json create mode 100644 src/ufs2ElectronApp/preload.js create mode 100644 src/ufs2ElectronApp/renderer/app.js create mode 100644 src/ufs2ElectronApp/renderer/index.html create mode 100644 src/ufs2ElectronApp/renderer/style.css create mode 100644 src/ufs2ElectronModule/README.md create mode 100644 src/ufs2ElectronModule/index.js create mode 100644 src/ufs2ElectronModule/package.json create mode 100644 src/ufs2ElectronModule/preload.js create mode 100644 src/ufs2ElectronModule/renderer/ufs2Renderer.js create mode 100644 src/ufs2ElectronModule/src/ipcHandlers.js create mode 100644 src/ufs2ElectronModule/src/ufs2Core.js create mode 100644 src/ufs2ElectronModule/test-smoke.js create mode 100644 src/ufs2ElectronModule/test.bat create mode 100644 src/ufs2ElectronModule/test.js create mode 100644 src/web-server.js create mode 100644 src/xavatarElectronModule/README.md create mode 100644 src/xavatarElectronModule/examples/basic-usage.js create mode 100644 src/xavatarElectronModule/examples/electron-integration/index.html create mode 100644 src/xavatarElectronModule/examples/electron-integration/main.js create mode 100644 src/xavatarElectronModule/examples/electron-integration/package.json create mode 100644 src/xavatarElectronModule/examples/test-avatar.xavatar create mode 100644 src/xavatarElectronModule/index.js create mode 100644 src/xavatarElectronModule/package-lock.json create mode 100644 src/xavatarElectronModule/package.json create mode 100644 src/xavatarElectronModule/preload.js create mode 100644 src/xavatarElectronModule/renderer/xavatarRenderer.js create mode 100644 src/xavatarElectronModule/src/dxt5Encoder.js create mode 100644 src/xavatarElectronModule/src/ipcHandlers.js create mode 100644 src/xavatarElectronModule/src/xavatarCore.js create mode 100644 src/xavatarElectronModule/test/basic.js create mode 100644 src/y2jb-generator.js create mode 100644 test/autonomous/build-kstuff-diagnostic-loader.test.mjs create mode 100644 test/autonomous/inspect-ps5-fih-route.test.mjs create mode 100644 test/autonomous/parse-kstuff-mailbox-capture.test.mjs create mode 100644 test/baseline-fixture-harness.test.js create mode 100644 test/bdjb-generator.test.js create mode 100644 test/fixtures/ch-1204-etahen-link-ui.html create mode 100644 test/fixtures/chimera-baseline-fixtures.json create mode 100644 test/fixtures/ps5-data-dlc/data/owned-content.txt create mode 100644 test/fixtures/ps5-data-dlc/sce_sys/param.json create mode 100644 test/navigation.test.js create mode 100644 test/platform-capabilities.test.js create mode 100644 test/server/app-server.test.js create mode 100644 test/server/audit-log-service.test.js create mode 100644 test/server/backup-replication-service.test.js create mode 100644 test/server/build-info.test.js create mode 100644 test/server/ch-0003-registry-queue-completion.test.js create mode 100644 test/server/ch-0107-library-contract.test.js create mode 100644 test/server/ch-0108-legacy-write-guard.test.js create mode 100644 test/server/ch-0109-migration-gate.test.js create mode 100644 test/server/ch-0201-title-workspace.test.js create mode 100644 test/server/ch-0202-protection-policy.test.js create mode 100644 test/server/ch-0203-storage-inventory.test.js create mode 100644 test/server/ch-0204-install-destination.test.js create mode 100644 test/server/ch-0205-drive-locator.test.js create mode 100644 test/server/ch-0206-guided-move.test.js create mode 100644 test/server/ch-0207-game-move-adapter.test.js create mode 100644 test/server/ch-0301-import-upload-http.test.js create mode 100644 test/server/ch-0301-import-upload-service.test.js create mode 100644 test/server/ch-0302-import-staging.test.js create mode 100644 test/server/ch-0303-archive-worker.test.js create mode 100644 test/server/ch-0304-import-classifier.test.js create mode 100644 test/server/ch-0305-portable-manifest.test.js create mode 100644 test/server/ch-0306-import-wizard.test.js create mode 100644 test/server/ch-0401-package-set-persistence.test.js create mode 100644 test/server/ch-0402-install-recipe-validation.test.js create mode 100644 test/server/ch-0403-recipe-editor.test.js create mode 100644 test/server/ch-0404-install-orchestrator.test.js create mode 100644 test/server/ch-0405-ps4-pkg-installer-adapter.test.js create mode 100644 test/server/ch-0406-ps5-content-adapters.test.js create mode 100644 test/server/ch-0407-post-install-verification.test.js create mode 100644 test/server/ch-0501-game-update-evidence.test.js create mode 100644 test/server/ch-0502-update-lifecycle.test.js create mode 100644 test/server/ch-0504-package-library.test.js create mode 100644 test/server/ch-0601-compatibility-provider.test.js create mode 100644 test/server/ch-0602-compatibility-guidance.test.js create mode 100644 test/server/ch-0603-patch-bundle.test.js create mode 100644 test/server/ch-0604-apr-ampr-adapter.test.js create mode 100644 test/server/ch-0701-media-cache.test.js create mode 100644 test/server/ch-0702-media-import.test.js create mode 100644 test/server/ch-0703-expert-navigation.test.js create mode 100644 test/server/ch-0704-settings-identity.test.js create mode 100644 test/server/ch-0705-p7-gate.test.js create mode 100644 test/server/ch-0901-trophy-snapshots.test.js create mode 100644 test/server/ch-0902-local-trophy-inventory.test.js create mode 100644 test/server/ch-0903-trophy-mapping-diff.test.js create mode 100644 test/server/ch-0904-trophy-reconstruction-plan.test.js create mode 100644 test/server/ch-0905-trophy-action.test.js create mode 100644 test/server/ch-0906-trophy-ui.test.js create mode 100644 test/server/ch-0907-system-backup-recovery-lab.test.js create mode 100644 test/server/ch-0908-save-recovery-plan.test.js create mode 100644 test/server/ch-0909-p9-gate.test.js create mode 100644 test/server/ch-0910-live-trophy-catalog.test.js create mode 100644 test/server/ch-0911-imported-trophy-definitions.test.js create mode 100644 test/server/ch-0911-legacy-detached-trophy-preflight.test.js create mode 100644 test/server/ch-0912-inactive-legacy-trophy-unlock.test.js create mode 100644 test/server/ch-0913-legacy-detached-trophy-mutation-adapter.test.js create mode 100644 test/server/ch-0913-psn-history-import.test.js create mode 100644 test/server/ch-0914-trophy-history-database.test.js create mode 100644 test/server/ch-0915-trophy-browser-auth.test.js create mode 100644 test/server/ch-1001-v3-performance.test.js create mode 100644 test/server/ch-1002-migration-rehearsal.test.js create mode 100644 test/server/ch-1101-ps4-package-backup.test.js create mode 100644 test/server/ch-1102-ps5-native-packages.test.js create mode 100644 test/server/ch-1103-trophy-progression-database.test.js create mode 100644 test/server/ch-1103-trophy-progression-integration.test.js create mode 100644 test/server/ch-1103-trophy-progression-planner.test.js create mode 100644 test/server/ch-1104-ps5-console-staging.test.js create mode 100644 test/server/ch-1105-ps5-profile-management.test.js create mode 100644 test/server/ch-1106-linux-listener-process.test.js create mode 100644 test/server/ch-1106-ps5upload-runtime.test.js create mode 100644 test/server/ch-1107-ps5-fpkg-installer.test.js create mode 100644 test/server/ch-1108-ps5-package-comparison.test.js create mode 100644 test/server/ch-1109-ps5-upstream-control-package.test.js create mode 100644 test/server/ch-1110-ps5-launch-reference-package.test.js create mode 100644 test/server/ch-1111-etahen-dpi-v2-installer.test.js create mode 100644 test/server/ch-1201-etahen-link-contract.test.js create mode 100644 test/server/ch-1202-etahen-link-agent.test.js create mode 100644 test/server/ch-1203-etahen-link-inventory.test.js create mode 100644 test/server/ch-1204-etahen-link-ux.test.js create mode 100644 test/server/ch-1205-etahen-shim-installer.test.js create mode 100644 test/server/ch-1206-etahen-link-orchestrator.test.js create mode 100644 test/server/ch-1207-etahen-link-operations.test.js create mode 100644 test/server/ch-1301-clean-ps5-fpkg-builder.test.js create mode 100644 test/server/ch-1302-ps5-fpkg-readiness.test.js create mode 100644 test/server/ch-1303-ps5-fpkg-artifacts.test.js create mode 100644 test/server/ch-1304-ps5-fpkg-launch.test.js create mode 100644 test/server/ch-1305-ps5-fpkg-ux.test.js create mode 100644 test/server/ch-1306-ps5-fpkg-hardware-gate.test.js create mode 100644 test/server/ch-1401-autonomous-dependency-gate.test.js create mode 100644 test/server/ch-1401-p15-a10-product-lane.test.js create mode 100644 test/server/ch-1401-p15-a10-shellui-transition.test.js create mode 100644 test/server/ch-1401-p15-a11-retained-launch-evidence.test.js create mode 100644 test/server/ch-1401-p15-a7-active-preflight.test.js create mode 100644 test/server/ch-1401-p15-a7-route-selection-source.test.js create mode 100644 test/server/ch-1401-p15-a7-route-selection-stage.test.js create mode 100644 test/server/ch-1401-p15-a7-route-selection-trace.test.js create mode 100644 test/server/ch-1401-p15-a8-completion-source.test.js create mode 100644 test/server/ch-1401-p15-a8-completion-stage.test.js create mode 100644 test/server/ch-1401-p15-a8-completion-trace.test.js create mode 100644 test/server/ch-1401-p15-a8-control-candidates.test.js create mode 100644 test/server/ch-1401-p15-a8-control-metadata.test.js create mode 100644 test/server/ch-1401-p15-a8-control-qualification.test.js create mode 100644 test/server/ch-1401-p15-a8-distinct-control-install-gate.test.js create mode 100644 test/server/ch-1401-p15-a8-distinct-control-package.test.js create mode 100644 test/server/ch-1401-p15-a8-experiment-ledger.test.js create mode 100644 test/server/ch-1401-p15-r1-control-taxonomy.test.js create mode 100644 test/server/ch-1401-p15-r1-profile.test.js create mode 100644 test/server/ch-1401-p15-r2-installed-content-verifier.test.js create mode 100644 test/server/ch-1401-p15-r2-product-lane.test.js create mode 100644 test/server/ch-1401-p15-r2-qualification-service.test.js create mode 100644 test/server/ch-1401-p15-r2-qualification.test.js create mode 100644 test/server/ch-1401-p15-r3-host-artifact.test.js create mode 100644 test/server/ch-1401-ps5-fpkg-content-auth-trace.test.js create mode 100644 test/server/ch-1401-ps5-fpkg-kstuff-runtime-identity.test.js create mode 100644 test/server/ch-1401-ps5-fpkg-runtime-qualification.test.js create mode 100644 test/server/ch-1401-ps5-fpkg-safe-runtime-stage.test.js create mode 100644 test/server/ch-1401-ps5-fpkg-semantic-reader-source.test.js create mode 100644 test/server/ch-1401-ps5-fpkg-semantic-reader-stage.test.js create mode 100644 test/server/ch-1401-ps5upload-runtime-identity.test.js create mode 100644 test/server/chimera-beacon-client.test.js create mode 100644 test/server/command-registry.test.js create mode 100644 test/server/companion-worker-service.test.js create mode 100644 test/server/console-profile-service.test.js create mode 100644 test/server/console-scan-reconciler.test.js create mode 100644 test/server/conversion-service.test.js create mode 100644 test/server/database-migrations.test.js create mode 100644 test/server/dependency-security-regressions.test.js create mode 100644 test/server/docker-runtime.test.js create mode 100644 test/server/ecosystem-intelligence-service.test.js create mode 100644 test/server/ecosystem-service.test.js create mode 100644 test/server/elf-arsenal-client.test.js create mode 100644 test/server/ezremote-server-client.test.js create mode 100644 test/server/ftp-list-dir.test.js create mode 100644 test/server/ftp-scan-root-coverage.test.js create mode 100644 test/server/functional-modules.test.js create mode 100644 test/server/game-metadata-view.test.js create mode 100644 test/server/game-metadata.test.js create mode 100644 test/server/game-update-service.test.js create mode 100644 test/server/github-security.test.js create mode 100644 test/server/headless-database.test.js create mode 100644 test/server/local-library-scanner.test.js create mode 100644 test/server/notification-service.test.js create mode 100644 test/server/operations-service.test.js create mode 100644 test/server/payload-manager-autoload-repair-service.test.js create mode 100644 test/server/payload-manager-proxy.test.js create mode 100644 test/server/payload-runtime-service.test.js create mode 100644 test/server/ps5-klog-service.test.js create mode 100644 test/server/ps5-payload-manager-client.test.js create mode 100644 test/server/ps5debug-readonly-client.test.js create mode 100644 test/server/public-readiness-security.test.js create mode 100644 test/server/pwa-security.test.js create mode 100644 test/server/read-only-sql.test.js create mode 100644 test/server/remote-play-service.test.js create mode 100644 test/server/remote-play-worker.test.js create mode 100644 test/server/renderer-security.test.js create mode 100644 test/server/security.test.js create mode 100644 test/server/server-login-client.test.js create mode 100644 test/server/source-classification-service.test.js create mode 100644 test/server/source-scan-route.test.js create mode 100644 test/server/title-workspace-a11y.test.js create mode 100644 test/server/totp.test.js create mode 100644 test/server/transfer-service.test.js create mode 100644 test/server/v3-derived-views.test.js create mode 100644 test/server/v3-repositories.test.js create mode 100644 test/server/web-command-contract.test.js create mode 100644 test/server/websocket-backpressure.test.js create mode 100644 test/system-state-client.test.js create mode 100644 test/y2jb-generator.test.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..22c5422 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,39 @@ +.git +.github +.codex +.gitea-deploy +.playwright-mcp +temp-*/ +node_modules +**/node_modules +dist +out +output +coverage +test-results +build/diagnostics +*.log +.env +.env.* +!.env.example +UFS2Tool +src/ufs2ElectronApp +src/ufs2ElectronModule +src/xavatarElectronModule/examples +**/*.exe +**/*.dll +**/*.pdb +**/*.bat +**/*.ps1 +!remote-play-worker/install.ps1 +!remote-play-worker/uninstall.ps1 +docs/obsidian +test +scripts +tools/chimera-prospero-bridge/bin +tools/chimera-prospero-bridge/obj +main.js +preload.js +donation-preload.js +STATUS.md +errors.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b293130 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +CHIMERA_PUBLIC_ORIGIN=https://chimera.example.com +CHIMERA_ALLOWED_HOSTS=chimera.example.com,localhost,127.0.0.1,chimera +CHIMERA_SECRET_KEY_PATH=/secure/chimera/master.key +CHIMERA_BOOTSTRAP_TOKEN_PATH=/secure/chimera/bootstrap.token diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0141434 --- /dev/null +++ b/.gitignore @@ -0,0 +1,127 @@ +# ── Dependencies ────────────────────────────────────────────────────────────── +node_modules/ +**/node_modules/ +.pnp.* + +# ── Build / packaging output ────────────────────────────────────────────────── +/dist/ +**/dist/ +/out/ +/output/ +build-output/ +release/ +**/bin/ +**/obj/ +*.asar +*.asar.unpacked/ +*.exe.blockmap +*.dmg +*.AppImage +*.zip +*.7z + +# ── Logs & runtime output ───────────────────────────────────────────────────── +*.log +logs/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +.playwright-cli/ +playwright-report/ +test-results/ +coverage/ +.nyc_output/ +UFS2Tool/err.txt +UFS2Tool/out.txt + +# ── Secrets & credentials (NEVER commit) ────────────────────────────────────── +.env +.env.* +!.env.example +.envrc +*.pem +*.key +*.p12 +*.pfx +*.ppk +*.keystore +*.jks +id_rsa* +id_ed25519* +*_rsa +*_ed25519 +secrets.json +secret.json +credentials.json +credential*.json +tokens.json +token*.json +*.secret +*.secrets +*.secret.* +*.secrets.* +*password*.txt +*passwd*.txt +*secret*.txt +*secrets*.txt +*token*.txt +*credential*.txt +*webhook*.txt +# 64-byte save encryption key dumped by GarlicSaves +garlicSavesKeybackup.txt + +# ── Local app/user state ────────────────────────────────────────────────────── +*.sqlite +*.sqlite3 +*.db +*.db-wal +*.db-shm +config.local.* +*.local.json +*.local.yaml +*.local.yml +*.local.toml + +# ── AI / assistant tooling ──────────────────────────────────────────────────── +.hermes/ +.claude/ +.cursor/ +.codex/ +.aider* +.continue/ +.opencode/ +.gemini/ +.roo/ +.windsurf/ +.mcp*.json +CLAUDE.local.md +AGENTS.local.md +*.local.md +.github/copilot-instructions.md + +# ── Stray working text files ────────────────────────────────────────────────── +# Keep packaged runtime text under build/**, but ignore scratch text at repo root. +/autoload.txt +/*.txt +!/README.md + +# ── OS / editor cruft ───────────────────────────────────────────────────────── +Thumbs.db +ehthumbs.db +desktop.ini +.DS_Store +*.swp +*~ +.idea/ +.vscode/ + +# ── Codex autonomous bundle exceptions ─────────────────────────────────────── +# Keep project-scoped Codex governance in this private autonomous bundle. +!.codex/ +!.codex/config.toml +!.codex/agents/ +!.codex/agents/*.toml +!/MASTERPROMPT_CODEX_SPARK_COPY_PASTE.txt +.autonomy-runtime/ +*.tmp-* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6fead68 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,5 @@ +# Contributing + +This public edition is generated from the private canonical repository. Report reproducible bugs or propose changes with a clear purpose and synthetic validation evidence. The maintainer applies accepted changes to the canonical source before the next curated publication. + +Do not include console credentials, account identifiers, real library data, private endpoints, firmware, game packages or unverified third-party binaries. Original source contributions are covered by Apache License 2.0; third-party material must retain its own compatible terms and provenance. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..56135d2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM node:24-bookworm-slim +WORKDIR /app +ENV NODE_ENV=production CHIMERA_DATA_DIR=/data +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force +COPY --chown=node:node src ./src +COPY --chown=node:node renderer ./renderer +COPY --chown=node:node build ./build +COPY --chown=node:node server.js ./server.js +RUN mkdir /data && chown node:node /data +USER node +EXPOSE 6967 +VOLUME ["/data"] +CMD ["node", "server.js"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LICENSE_DECISION.md b/LICENSE_DECISION.md new file mode 100644 index 0000000..7c3246f --- /dev/null +++ b/LICENSE_DECISION.md @@ -0,0 +1,5 @@ +# License decision + +On 2026-10-01 the repository owner selected an appropriate public license and confirmed that the original Porkfolio code has been fully replaced. The earlier pending-license recommendation is superseded. + +Original Chimera code is licensed under Apache License 2.0; see [LICENSE](LICENSE) and [NOTICE](NOTICE). Historical provenance remains documented in [UPSTREAM.md](UPSTREAM.md). Third-party dependencies and assets keep their own licenses. Unknown third-party binaries are excluded from the curated public source and public releases. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..916ab73 --- /dev/null +++ b/NOTICE @@ -0,0 +1,8 @@ +Chimera +Copyright 2026 Jens / ITWorx + +Original Chimera source is licensed under the Apache License, Version 2.0. +Porkfolio by StonedModder is acknowledged as the historical starting point. +The repository owner confirmed on 2026-10-01 that its original code has been fully replaced. +Third-party dependencies, tools, firmware, payloads and assets retain their own terms; +this license grants no additional rights to those components. diff --git a/PUBLIC_SOURCE_EXPORT.md b/PUBLIC_SOURCE_EXPORT.md new file mode 100644 index 0000000..d5a9d86 --- /dev/null +++ b/PUBLIC_SOURCE_EXPORT.md @@ -0,0 +1,3 @@ +# Public source export + +This source snapshot was generated from the private canonical repository at revision `a8ae5b1db07fb49926c94585e2718e5130330974`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. diff --git a/PUBLIC_SOURCE_MANIFEST.json b/PUBLIC_SOURCE_MANIFEST.json new file mode 100644 index 0000000..d245676 --- /dev/null +++ b/PUBLIC_SOURCE_MANIFEST.json @@ -0,0 +1,2651 @@ +{ + "schemaVersion": 1, + "sourceRevision": "a8ae5b1db07fb49926c94585e2718e5130330974", + "files": [ + { + "path": ".dockerignore", + "sha256": "7bbc823465084f103e95d0beb8cd80fec2e47de4454d6e888ad10644bafbab16", + "bytes": 568 + }, + { + "path": ".env.example", + "sha256": "d800d531e1ea01b1653631245baea59b820f6747204d9e51c590e4e3ca38c13b", + "bytes": 236 + }, + { + "path": ".gitignore", + "sha256": "68f564eb5a0cf66ba6b6a180faead28775feca33380c6f2e4a1c3cb850a23e81", + "bytes": 3262 + }, + { + "path": "CONTRIBUTING.md", + "sha256": "6de925142332abf8ebb84908c392640d4d0194aa8967d3d01005d33dfbf98f90", + "bytes": 589 + }, + { + "path": "Dockerfile", + "sha256": "022200a92fff2a7a2a7b6bd071ad96ba0816c9bfd2cd6beec12bfac8d535d2a9", + "bytes": 470 + }, + { + "path": "LICENSE", + "sha256": "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30", + "bytes": 11358 + }, + { + "path": "LICENSE_DECISION.md", + "sha256": "a895de868db7ad8a14408dace0d45b9f461015f88482613aed557be42aea1465", + "bytes": 562 + }, + { + "path": "NOTICE", + "sha256": "baba0eeb9093b701b0bea177029f92e9db6de04cfb76cb4f47298bc70dfe741c", + "bytes": 439 + }, + { + "path": "README.md", + "sha256": "f8ddee78ee5231a2efa3f3e941a2acab8679107063e98b7acdb64e8455e0f786", + "bytes": 6284 + }, + { + "path": "SECURITY.md", + "sha256": "2e89af99998f78979200c6ab13a604dcee75810117563ef4af95fc30c93f8274", + "bytes": 1594 + }, + { + "path": "THIRD_PARTY_NOTICES.md", + "sha256": "31c08302612b7b0f098d981913d8cc0ce07c28c7a8cdc53d352b2a85fa00395b", + "bytes": 788 + }, + { + "path": "UPSTREAM.md", + "sha256": "64ee3e0f7455515c0f242c2bfdc8b9417d57345459d3d988a8bcc2616249c60e", + "bytes": 700 + }, + { + "path": "build/icon.png", + "sha256": "7c9eadb384e0b2832e426045dfdbda4f1ed298ae5d3605c8beb90040172eb292", + "bytes": 353492 + }, + { + "path": "compose.yaml", + "sha256": "62f781e54ac70c58d263b1c6c257e3dd8254fe3fae44413821a7299be749810d", + "bytes": 972 + }, + { + "path": "docs/assets/chimera-overview.jpg", + "sha256": "4fefcf7972d9510250c6fac8eb6cc94fa2cfa880f2598316533d61866c4d7036", + "bytes": 96072 + }, + { + "path": "donation-preload.js", + "sha256": "bcbb55f1ba40f08b9116859d40960c1c678744a34ef96beed2a96061740a58e8", + "bytes": 804 + }, + { + "path": "main.js", + "sha256": "32ce5dec1a05c79e29afdf4570f32dddfcf9546770afe8fe19c45f3c56ee4a83", + "bytes": 173301 + }, + { + "path": "package-lock.json", + "sha256": "ce9908e0a00f92212c0ba21dcb105ab229bd5eb996a327f2ef6e76874cc322b8", + "bytes": 158716 + }, + { + "path": "package.json", + "sha256": "6137073e41fba1f5d0e6375f104d6539a234168f3ec04376d3ac8aff28f6772a", + "bytes": 3093 + }, + { + "path": "preload.js", + "sha256": "e2b2eb0a15aca53e72f0153ee66b8cb69ab177ca8fb963cb2fc7a7fe301e288c", + "bytes": 30156 + }, + { + "path": "remote-play-worker/README.txt", + "sha256": "5582b1eed293a0143e446b9ac7b5620438d1414875258c9b86deb5217f20bbb4", + "bytes": 2138 + }, + { + "path": "remote-play-worker/Start worker.cmd", + "sha256": "0b1c6d3b44055b63299b3e67789d241763a83abc30f37749535a51cc64079f7b", + "bytes": 232 + }, + { + "path": "remote-play-worker/install.ps1", + "sha256": "b219052d210ea85cba04389dd772e2a14df1de39be45ba581a9b4835b74ea5cd", + "bytes": 2895 + }, + { + "path": "remote-play-worker/uninstall.ps1", + "sha256": "99740736ec7a244e001e28250175733873bf053aa8449cbffe67557d8223e32b", + "bytes": 885 + }, + { + "path": "remote-play-worker/worker.js", + "sha256": "9e5fe3fffeac06bb0b2816783c7ab64c9aa2d7a8c2037371cc3ec1cb41484412", + "bytes": 32212 + }, + { + "path": "renderer/assets/chimera-crest.svg", + "sha256": "78579542452b5b1d26872b7a556866aaa4373c7504b3b2a7e8193447a0610772", + "bytes": 1018 + }, + { + "path": "renderer/assets/fonts/OFL-Inter.txt", + "sha256": "d7cee39dfa656bffe74385c76debffe635788a83c00ce4370718c99d4c2650ff", + "bytes": 4469 + }, + { + "path": "renderer/assets/fonts/OFL-SpaceGrotesk.txt", + "sha256": "c9a6f76d065b19ee383e42c8a114915e733bcbaed9759de41f4016b64d502cd5", + "bytes": 4494 + }, + { + "path": "renderer/bootstrap.js", + "sha256": "a4c3a54949ad32a34cfa13c778f9a26379999d67265641aaf94061d8bf3522d5", + "bytes": 3588 + }, + { + "path": "renderer/donation.html", + "sha256": "829392a1e23d83db7b280f1b6cd47cee35502bd829da594fa5a6b679e1286e93", + "bytes": 12189 + }, + { + "path": "renderer/gif-encoder.js", + "sha256": "23ab8ce856cb1da96eccabfca9196330c01095b72f2d04b0636890feb2aa7ba8", + "bytes": 12026 + }, + { + "path": "renderer/index.html", + "sha256": "e04a63f0da7c88cea33a232735f069f29ffb1347dc134de54736d6f0b7022e98", + "bytes": 219588 + }, + { + "path": "renderer/manifest.webmanifest", + "sha256": "7e5e551c6f8eb20d59ac0137455ca8c12a5f63f738f91eda6313ad0def6ebb07", + "bytes": 423 + }, + { + "path": "renderer/modules/autoloader.js", + "sha256": "1982ef0ed15463ce4f76ebc020076284d7bca88899acd24d33bf77a2fdcc3361", + "bytes": 22979 + }, + { + "path": "renderer/modules/backpork-gen.js", + "sha256": "091e8a7bf4b7a169bf8c12c596c8ccbc4f651ac2af706312bc895cd884b10d70", + "bytes": 31078 + }, + { + "path": "renderer/modules/backporks.js", + "sha256": "4ba878c957a279295ebeb2e85a60f3fb1fd5c80da13f1e025623d3a2b97b4d39", + "bytes": 27359 + }, + { + "path": "renderer/modules/backups.js", + "sha256": "7c6db7a5f413c94e4a0d8bfb1d2606b92fd489158fbb5350df152a7f2a22a751", + "bytes": 9152 + }, + { + "path": "renderer/modules/bdjb-generator.js", + "sha256": "41b212c186fe7f364b384d9e3f239ed49f103b7e551613a568128436d4923792", + "bytes": 3316 + }, + { + "path": "renderer/modules/cheats.js", + "sha256": "96c9d3216b58f59bbe01f982bf864d2bff493e3e6f2f95befd8bc588dacd262b", + "bytes": 19955 + }, + { + "path": "renderer/modules/compatibility-guidance.js", + "sha256": "918d02f018da92d9f4fc99083f27bb109c3c71ad594982292bad4f0be8b59df4", + "bytes": 3440 + }, + { + "path": "renderer/modules/connection.js", + "sha256": "899f93f287bacbb63ef791cb2f48bda7b188bbbcff2413db0db7d653d8c6246d", + "bytes": 13795 + }, + { + "path": "renderer/modules/dashboard-widgets.js", + "sha256": "8c9e7b0af14c54e3c5760fef477bdfba90c78b98353f22c77ab4dc487b3b2982", + "bytes": 15429 + }, + { + "path": "renderer/modules/database.js", + "sha256": "9b024136c4e3bf260f900298e740252fe36f4cc68e58ba3d7d7623e4863be195", + "bytes": 24427 + }, + { + "path": "renderer/modules/ecosystem.js", + "sha256": "87dfd13833120a203c73308960998c4c4922a037a6416e3ac9888b5f7622dcfa", + "bytes": 180518 + }, + { + "path": "renderer/modules/elf-arsenal.js", + "sha256": "f34f28bbcf9eaa079e3c19e8b65e08dd4ce97dbbe2d317edd0e18f67f4b53f58", + "bytes": 11332 + }, + { + "path": "renderer/modules/etahen-link-ux.js", + "sha256": "c968882038dbac0b43e4a95d7fce2d04bae98fdb308a3396539e7ead8622b958", + "bytes": 4969 + }, + { + "path": "renderer/modules/ftpbrowser.js", + "sha256": "1399139740204c3dfb0e0558455ffd0015cf3bd361cb4989f4afef4db4a956c1", + "bytes": 4687 + }, + { + "path": "renderer/modules/game-source-scan.js", + "sha256": "fe745e0b0b46014c552e30d6ad2199ad10cb41b3be2ac8d77801faa151ab2307", + "bytes": 725 + }, + { + "path": "renderer/modules/games.js", + "sha256": "142795b8c5fa41a425ab7233077937f4480d7aedf4387e0d8a7a2a277f3ff3f3", + "bytes": 25908 + }, + { + "path": "renderer/modules/hash.js", + "sha256": "ddae78c48ea8d2eb5815e0d4374c744d894feece52fac5782360be4cea51500f", + "bytes": 7167 + }, + { + "path": "renderer/modules/helpers.js", + "sha256": "e0dd54b537db1b67c09c870d0a66e2ae0c668220e6cb63ab2cbd12421cb5bbfe", + "bytes": 8884 + }, + { + "path": "renderer/modules/hotkeys.js", + "sha256": "441efe8548504fbede24de462c6241abdc02e38250f02c427ec5ee22ba4daaee", + "bytes": 5765 + }, + { + "path": "renderer/modules/i18n.js", + "sha256": "34ae78c7549b62d548ccb3fff9ec1f7b24def9fef0ac2af30b1d2cd8eb7c0d03", + "bytes": 3267 + }, + { + "path": "renderer/modules/import-wizard.js", + "sha256": "5c30b358ca340d22f506084b1a44a8b2084b40a509a5bb708d0d58db3419640a", + "bytes": 16020 + }, + { + "path": "renderer/modules/init.js", + "sha256": "ff83950431b6a2bc22005d0145b11b4a71c2cad6e64d79d35c67866ffea24529", + "bytes": 4314 + }, + { + "path": "renderer/modules/install-destination.js", + "sha256": "b9c4693c9da35593701a696483bd5ebe2f6c23bd8e41d13a2b914b3a2b46c0ad", + "bytes": 4714 + }, + { + "path": "renderer/modules/library-model.js", + "sha256": "da3d95b2cd8c7cbdbe7898ac7b065a6f4feca6eb13de5b893d7e4e516a2924ed", + "bytes": 862 + }, + { + "path": "renderer/modules/manage.js", + "sha256": "b61e644d75451c7e00c792a939a7eeb805f213d512380ecb147a031c31da6b05", + "bytes": 16759 + }, + { + "path": "renderer/modules/media.js", + "sha256": "89b791132dab47fbe1338776db221c67535e2b4a5afc902a2b07a1a64d768f31", + "bytes": 36053 + }, + { + "path": "renderer/modules/modal.js", + "sha256": "f6e3532d68d1a8d2fe45a633656eaae4011c1f6acf5427f54c76e0fb8526ffde", + "bytes": 109344 + }, + { + "path": "renderer/modules/navigation-routes.js", + "sha256": "216a9f064925b9ed0ae3ad92f3581e98610918a8854538843325f15071d3cea4", + "bytes": 11508 + }, + { + "path": "renderer/modules/navigation.js", + "sha256": "f5a33fb16d4d21461e71fa39bb6674266c86b2349b884fd5793119cebeceb9ce", + "bytes": 61173 + }, + { + "path": "renderer/modules/operations.js", + "sha256": "7dafb0cd6c2f6428553dc674c8c29db65fade41be8fce13c946c9f8c6b7fb473", + "bytes": 51032 + }, + { + "path": "renderer/modules/packages.js", + "sha256": "86e11986d20ca27901925894c661bbcd11f2462784d33d04032cda9ca680f818", + "bytes": 3329 + }, + { + "path": "renderer/modules/payload.js", + "sha256": "6ac665fee39825486749bf2c5eedbfd568e44eb45d4ae7b091c7f9287f2bc8c0", + "bytes": 32403 + }, + { + "path": "renderer/modules/pfs-ripper.js", + "sha256": "bb4f734026e3193e56ed83d9eded959acbee0f260fec96122b5429b6b73092ba", + "bytes": 7255 + }, + { + "path": "renderer/modules/premium-illustrations.js", + "sha256": "f6880215e4033198c4878acca995a44a25fa75ab63107408db79d8e49333f096", + "bytes": 12036 + }, + { + "path": "renderer/modules/protection-model.js", + "sha256": "31b5eeae0b75e844f3e05414ecc0824372fb2add1c557d03461782cdc6b18806", + "bytes": 6076 + }, + { + "path": "renderer/modules/psnotify.js", + "sha256": "a2ff25551ef21fda5a6738f7586e30147d65723d8c71b3e71d53b83d0561296d", + "bytes": 12684 + }, + { + "path": "renderer/modules/pwa.js", + "sha256": "f135efc79c17d8099042ee0353548fa6e87ff4c34725c736a6ff52849e374bd3", + "bytes": 284 + }, + { + "path": "renderer/modules/savemgr.js", + "sha256": "86e96c6edabe95644e3914d94bbf0e86e8a875409040e360cb69502044fa9ed5", + "bytes": 26733 + }, + { + "path": "renderer/modules/settings.js", + "sha256": "8b6b945604e0b2e76926a7e9bf0590bd22b470f460e2fec05c9574571ebbcfd0", + "bytes": 43364 + }, + { + "path": "renderer/modules/state.js", + "sha256": "df4769d1bc82968b61a040fd57b8c0fb10b642c28a523db998d7970e81e13968", + "bytes": 21641 + }, + { + "path": "renderer/modules/system-state.js", + "sha256": "7f06c0ee96ae7980043aaa519313093fe5dc8f3a6b2573ba925479c3050e8721", + "bytes": 3417 + }, + { + "path": "renderer/modules/system-view.js", + "sha256": "7362d0efdf7b4ed38f3d09c3c100c3ab34d87938657e54a6ac594724a4ed4a0f", + "bytes": 30993 + }, + { + "path": "renderer/modules/title-workspace.js", + "sha256": "ce50fc238a81c44c6756451eb27ea6466d9ba7016d30ebbdf9c931f0ca9d0412", + "bytes": 13085 + }, + { + "path": "renderer/modules/transfers.js", + "sha256": "c3d0b9b1952b8ddd4a885d48aaa37b8378380afbea873c113d99a4afa8bcc2d1", + "bytes": 9989 + }, + { + "path": "renderer/modules/xavatar.js", + "sha256": "8fa7500af342332582805c623f048444b104573c06a26943a1cfe14c3ef03096", + "bytes": 34392 + }, + { + "path": "renderer/modules/y2jb-generator.js", + "sha256": "e140395723f5af8fe2ffd05e3a48bbcbf934b23cd640546eb1ca149e02522ff0", + "bytes": 9285 + }, + { + "path": "renderer/offline.html", + "sha256": "c79bfa43f40974e278e950416a73afaf1f41e4b8b9420cd53b102758c557f948", + "bytes": 1548 + }, + { + "path": "renderer/pork-evidence-bridge.js", + "sha256": "a0b3da14e4e3a2233bd89dab56bcdb1439de237ee87c7a4312211dd6a8145911", + "bytes": 806 + }, + { + "path": "renderer/pork-web-shim.js", + "sha256": "203c236770f605639f397aab4c15963a1bb525a990531f5f4ffe3584a8cfccaa", + "bytes": 37450 + }, + { + "path": "renderer/server-login.html", + "sha256": "68498cd9a3586007734d45889bc9198b0754aa2ec48c1bf775296c357fad5934", + "bytes": 3322 + }, + { + "path": "renderer/server-login.js", + "sha256": "e33a60950cfd5a46c72a73f2d676e3f0dd914912aca90dc7b1bc757565676160", + "bytes": 4130 + }, + { + "path": "renderer/service-worker.js", + "sha256": "065f1d01d2da24c08ccaffb38c29919f02237fd4eff2fde951f67bd4110f8a8a", + "bytes": 1651 + }, + { + "path": "renderer/splash.html", + "sha256": "7c54f22901528c5b1a824680e4da01edc83283311ac00cbde5c8b60791a16aad", + "bytes": 3076 + }, + { + "path": "renderer/styles/base.css", + "sha256": "7069e75584345a9f0494c6238f2a35436135e7ec2f72f2b86c8e72df417c8b51", + "bytes": 16951 + }, + { + "path": "renderer/styles/chimera-system.css", + "sha256": "180127c622b193ac8bb34f893ad76567eb9ffd44947e6330b7efbf736a03e472", + "bytes": 18569 + }, + { + "path": "renderer/styles/components.css", + "sha256": "81d04bd6cde80e05fbb0296d9a9119f5d296e019c7de2a63f978afb40ff8d225", + "bytes": 24802 + }, + { + "path": "renderer/styles/etahen-link.css", + "sha256": "e5debfc95dc22b800ce1ff02da12984e93c993fe84478422c1b03cbb898df2e4", + "bytes": 1635 + }, + { + "path": "renderer/styles/features.css", + "sha256": "596716bc164322423026971c8bbd15e2335b0033cea3840482ee0a99fe507ee3", + "bytes": 54708 + }, + { + "path": "renderer/styles/game-covers.css", + "sha256": "a1e6c59643f4da26b1b22c96b959d0be626783cbc62c7c6c9871784bf6537dbb", + "bytes": 2745 + }, + { + "path": "renderer/styles/import-wizard.css", + "sha256": "7c5db9e76f3913bbb9f33a9f8b22dafaf83e9ae45a72d536cbc85bcff5d42151", + "bytes": 6004 + }, + { + "path": "renderer/styles/install-destination.css", + "sha256": "c5fc79d855094eeac9d362d04108fc51a9cedd1d62b01bb7ccc15e18c65e0b35", + "bytes": 827 + }, + { + "path": "renderer/styles/mobile-companion.css", + "sha256": "1025b01cbf05481bcf0e1a7cf52867e17d5bbf7701f077508478e70e651e69b7", + "bytes": 14900 + }, + { + "path": "renderer/styles/navigation.css", + "sha256": "d581b0cbb0bfa93657b4969e2b2b3cb7332f0794fe3a7d214b1d33cacc9bca5b", + "bytes": 23140 + }, + { + "path": "renderer/styles/package-library.css", + "sha256": "8692a36837bd80f2233f743033426a3cbedd7ab1e2ee2db02a9366722b9a0ff0", + "bytes": 2117 + }, + { + "path": "renderer/styles/pages.css", + "sha256": "dc18e67b6474abab823b942e5e7cab2cbd72eb7633ab3118f5f1e9ed977c13fd", + "bytes": 57970 + }, + { + "path": "renderer/styles/premium.css", + "sha256": "1dba3b289480948db98e69e40de2dc9c7abf29590ed22885dbe76ef6d705543f", + "bytes": 11885 + }, + { + "path": "renderer/styles/protection-copies.css", + "sha256": "1bc3f3048a255675270aca1ebe96911481a26194874e05d8be7ba138299d6d8b", + "bytes": 2097 + }, + { + "path": "renderer/styles/ps4-packages.css", + "sha256": "b32130432b4ec72a203dab62d0a2c2766d6b8f6c1e7e15a8cb4118c483bdb08a", + "bytes": 4602 + }, + { + "path": "renderer/styles/ps5-native-packages.css", + "sha256": "8a315769c9969dda04fc1f2156030dafc0d6dbe02a378950815066feec4c685f", + "bytes": 11083 + }, + { + "path": "renderer/styles/ps5-profiles.css", + "sha256": "64da54a2fd5c72ce9b875273dbf1f3af0b190622c30c9dd8b5c26dd25eeb6046", + "bytes": 8970 + }, + { + "path": "renderer/styles/server-login.css", + "sha256": "abe7637e775f19a00c27aa240b66fc6e324d4f5d1636e07ac81c1cd1a551f7a6", + "bytes": 2105 + }, + { + "path": "renderer/styles/stitch.css", + "sha256": "0abaa19d07c4dfe12ebae42aea132a194edb32dd50e138508de829ecc70ed01a", + "bytes": 55210 + }, + { + "path": "renderer/styles/title-workspace.css", + "sha256": "781945d324bfb772117f31ea1647f0c3a1bb9749ba8f7eb3f8af7851cb5f1b3b", + "bytes": 1508 + }, + { + "path": "renderer/styles/trophies-import.css", + "sha256": "074abe7e49561be8967748ffca50a7677c78cc057265b5b859af2deeb4e9d3c5", + "bytes": 1925 + }, + { + "path": "renderer/styles/trophies-installed.css", + "sha256": "a0daeda01804ee83975fd3dc16fccf1d9344791ebf4c1d336068db4a19854976", + "bytes": 403 + }, + { + "path": "renderer/styles/trophies-library.css", + "sha256": "d9d9050fc74d07e5ce88cf688a88c201ce1d749bd2b03da71019a7203f649fb7", + "bytes": 12882 + }, + { + "path": "renderer/styles/trophies-live.css", + "sha256": "aecb6a6e780a31e91afbc00686da5c780ea2eb6bd6cbf342337e5c10b52ce220", + "bytes": 1236 + }, + { + "path": "renderer/styles/trophies-progression.css", + "sha256": "1d71843cf053b32f1209f61516495a3286e73e860215211b2c9b4657bfa62e6e", + "bytes": 1949 + }, + { + "path": "renderer/styles/trophies-unlock.css", + "sha256": "0931ceac1156665ee86ea858eac6c3a6f13e7b1d5c0c3a1076f32438c6f94654", + "bytes": 839 + }, + { + "path": "renderer/styles/trophies.css", + "sha256": "268b409ae7f99e98d9cad2df3ea16e9f4b24ccae7fdf5a9ecf0808313fd31d9e", + "bytes": 4152 + }, + { + "path": "renderer/system-view.html", + "sha256": "61d623c56db3859767c9c2b7087b0693bee08e53205ff9136a990798981dc5e6", + "bytes": 19704 + }, + { + "path": "schemas/chimera-title-manifest.schema.json", + "sha256": "c5dc1805dcffe36a39d65d8c1df9aed1547a36d1a0016f89e5fced1a364b6158", + "bytes": 4418 + }, + { + "path": "schemas/compatibility-provider.schema.json", + "sha256": "c1405afd7258f645bc0e150cb7d46254b54a87684bb4424067af228858f2bbc8", + "bytes": 3660 + }, + { + "path": "schemas/install-recipe.schema.json", + "sha256": "e4178d441dcd36f028fa9d6109455b58d6f440f773e85591efffa1d0c37f318e", + "bytes": 2621 + }, + { + "path": "schemas/ps4-package-backup-manifest.schema.json", + "sha256": "b061fc0403dcd337efab10e1f57607e1797b79c2acb7e371041565632fbaaef4", + "bytes": 1991 + }, + { + "path": "schemas/ps5-native-package-manifest.schema.json", + "sha256": "c7b22841343bdada558f184dc0672c640bc614f6713f920a94e9cec443e95190", + "bytes": 4794 + }, + { + "path": "schemas/task-queue.schema.json", + "sha256": "6e3fd6cd8c51e9cf5ba0541d254e2fc94911f4d0b32707fceff52baa3780eeb0", + "bytes": 3473 + }, + { + "path": "schemas/trophy-local-inventory.schema.json", + "sha256": "666eec68f50b63dba97146925fec29c0d9f1ef52460f02658958204f39f293a2", + "bytes": 3234 + }, + { + "path": "schemas/trophy-psn-proof.schema.json", + "sha256": "0be4d82c9310f0d272133c4eec1cb6beb43511b2a78e4420ec2647828ec6f7c1", + "bytes": 4123 + }, + { + "path": "scripts/check-syntax.js", + "sha256": "a3533058d2b8b8eb6e0e034d5a35d65a2c8c74b04257211a976348587c35a4aa", + "bytes": 2157 + }, + { + "path": "server.js", + "sha256": "f8e860f2dd0b292573ed7c0e2477d6d02fe68da3b2fad2d65303eb3af7d87af0", + "bytes": 11531 + }, + { + "path": "src/backpork-gen/decrypt-fself.js", + "sha256": "9dd4a08146180c7d2e08d254503b697f64dbb39cf353b8847d8156272b72c84e", + "bytes": 5438 + }, + { + "path": "src/backpork-gen/fakelib-manager.js", + "sha256": "b61ca3eb4e808e0865f8d5425b2516baf024d16703ff58c65b3cb7821acfabf2", + "bytes": 12452 + }, + { + "path": "src/backpork-gen/make-fself.js", + "sha256": "d2b34719414b139bddcf6baa104b9f81dd22ceed708006473e8dc93d76daa7d4", + "bytes": 10243 + }, + { + "path": "src/backpork-gen/processor-worker.js", + "sha256": "f27e921883f412f9de4dbdc8d78b674b932fb3c9888d87623d593668c1522135", + "bytes": 1514 + }, + { + "path": "src/backpork-gen/processor.js", + "sha256": "7afc4f8f8607bd48c1a982006d88aa1cb0a1681c529a9351610e42ffaf35976b", + "bytes": 13049 + }, + { + "path": "src/backpork-gen/sdk-patcher.js", + "sha256": "a81f7f757d4acec249043cbee67765b2566dacbf6448e16c9bed155714c93fc7", + "bytes": 5206 + }, + { + "path": "src/bdjb-generator.js", + "sha256": "49ab4c82c11a841adddf58b17b9c991788505b1a56daf03e5ce85bd499c1ce08", + "bytes": 3595 + }, + { + "path": "src/cheats.js", + "sha256": "b8ca41d7a6d852dc79715c350b1c2a0cadc1be631751eb009adcc1fe4d6e96ed", + "bytes": 2666 + }, + { + "path": "src/core/command-registry.js", + "sha256": "cca98b8cf36fe8129bfba8ff43af521d8e05bd02c8dee994c5a30564b8bde014", + "bytes": 7363 + }, + { + "path": "src/database.js", + "sha256": "4ef5b501b098cd5603048e9679c582519b4253ca43f84bd9e00d2ae4ba78455e", + "bytes": 57700 + }, + { + "path": "src/database/migrations/0001-create-core-schema.js", + "sha256": "50fc5fe409c7e1b5e5c05fd625f9cd4a4f0aca69ed7133b118841ece7427bada", + "bytes": 5205 + }, + { + "path": "src/database/migrations/0002-migrate-legacy-columns.js", + "sha256": "a91c69312fa148701f40eab827e3d6e9b41bf44da6078075896f6735d1c5288e", + "bytes": 6488 + }, + { + "path": "src/database/migrations/0003-create-v3-core.js", + "sha256": "965e17934f25e3365d5ec99bc9258db51e2655132eeffc9fda4b96ae2a2b078a", + "bytes": 14625 + }, + { + "path": "src/database/migrations/0004-migrate-legacy-games-to-v3.js", + "sha256": "6cc0469c2a16be15f5bf72b0bc0ca33fdd2e8f14b11faff9197983f07aaac08c", + "bytes": 10885 + }, + { + "path": "src/database/migrations/0005-create-semantic-evidence-overrides.js", + "sha256": "bca7567138b4e3106d54df8cb59825b2a3b8671771b40737c7890fcf17e0d2fd", + "bytes": 713 + }, + { + "path": "src/database/migrations/0006-add-storage-evidence.js", + "sha256": "ee5344fb8dab94f3e49c93d724e9fee1790462ac1428faaa38804d79e749467b", + "bytes": 1132 + }, + { + "path": "src/database/migrations/0007-add-storage-friendly-label.js", + "sha256": "5be44d7f2e1b20ebdf52c75ea68e07638db7f6f98971a771f343c71fa7386b31", + "bytes": 384 + }, + { + "path": "src/database/migrations/0008-create-guided-game-moves.js", + "sha256": "7707b1e3f2a8fcd7ba4f8bf17c78057add2162deca08992b5ee26671bc208d2e", + "bytes": 1011 + }, + { + "path": "src/database/migrations/0009-create-package-sets.js", + "sha256": "018506e717ce92d7728c08c2ac0106116b296a56639f440cc39d01a873023b05", + "bytes": 4777 + }, + { + "path": "src/database/migrations/0010-create-install-recipes.js", + "sha256": "bce17020612d6f6a8e5404b1eac659be33728ce5dc8b420c12ee466853f0984d", + "bytes": 2232 + }, + { + "path": "src/database/migrations/0011-create-compatibility-findings.js", + "sha256": "d23ff3d4dbdb53f5127ba7ea941f620ac913c577a43fefe65f45334d48512be9", + "bytes": 2026 + }, + { + "path": "src/database/migrations/0012-create-trophy-psn-snapshots.js", + "sha256": "2419d43e69fe748feb07c3305b8e112fb4b8cc881a14e4867d67b9f9c717a68b", + "bytes": 1646 + }, + { + "path": "src/database/migrations/0013-create-trophy-local-snapshots.js", + "sha256": "450e6c3faf21a95bfa063383afe030c2782d09ed2e8d44ab9e52f22dbeacd6dc", + "bytes": 1856 + }, + { + "path": "src/database/migrations/0014-create-trophy-account-mappings.js", + "sha256": "1f2af370792015e62a16a8ac76349c36a89048d10d9537f2b5035ad5c729591a", + "bytes": 1142 + }, + { + "path": "src/database/migrations/0015-create-trophy-reconstruction-plans.js", + "sha256": "0bd1a4bdd750e54ce9e7dce23ed315ec0afa49f0bbe0b50f16e00131aa446510", + "bytes": 2094 + }, + { + "path": "src/database/migrations/0016-create-trophy-reconstruction-journals.js", + "sha256": "3f4871569e8ae9009e7d6e92b0c8f3a7ade0e2cfdc6449525006fe6adf2c5439", + "bytes": 2605 + }, + { + "path": "src/database/migrations/0017-create-ps5-system-backup-inventory.js", + "sha256": "faf56b7515cab5aa4dcc451a93a5584d5c63a8dce4317d2413fdfebb4ed1f685", + "bytes": 1498 + }, + { + "path": "src/database/migrations/0018-create-save-recovery-plans.js", + "sha256": "07abc6f9d191314564df1355aa63cedacc00370662eff8e80a9031178111cb7d", + "bytes": 1827 + }, + { + "path": "src/database/migrations/0019-add-v3-performance-indexes.js", + "sha256": "b78c003260038f873fdef98c17a7e78c05a199b7d7a8b4fbe6bb352dcbaa428c", + "bytes": 974 + }, + { + "path": "src/database/migrations/0020-create-trophy-progression-plans.js", + "sha256": "bb8f9c407e43e7c8f550ede02c4de16ae6cc3d6bb382f3af3c93a5e63708cb11", + "bytes": 1338 + }, + { + "path": "src/exfat/native-exfat.js", + "sha256": "c4e65fa294a5b5abf2b1214e420bf23917d65876462fa7c4f3d896e281a9dd1d", + "bytes": 13031 + }, + { + "path": "src/ftp.js", + "sha256": "b6bf7c162a1ae641925655251dc0a521882f0b4e2932c474d40d3681803af105", + "bytes": 35585 + }, + { + "path": "src/github.js", + "sha256": "c7ade0313adad8f9e16dad7104594afed8cc4ed0bcf0f08f586297bd1fb2ec50", + "bytes": 13855 + }, + { + "path": "src/hashing.js", + "sha256": "0cec1820bdcfee0dd114d59c7d71ac2979f3746ec20652673e337ace357a8e42", + "bytes": 973 + }, + { + "path": "src/ipc/backpork-gen.js", + "sha256": "213af4286c508c8fbda459a4773e452de6227be37f403b733370a99a9ffe5387", + "bytes": 15262 + }, + { + "path": "src/ipc/bdjb-generator.js", + "sha256": "313140cfae7cfb3df355e3f3d16811ce77ead1ee573a33a86d2840b9af696c76", + "bytes": 4277 + }, + { + "path": "src/ipc/conversion.js", + "sha256": "edd21fd865c13b4f6c2aa919bc8461c436e738d798a9220e96dbe31b4a41b1c0", + "bytes": 34356 + }, + { + "path": "src/ipc/elf-arsenal.js", + "sha256": "e867ad7a43b216f10d0a7e6e776342a16ea7764feb478c4f329c0f3b4110517e", + "bytes": 8806 + }, + { + "path": "src/ipc/garlic-saves.js", + "sha256": "6706758c119c83eb6b09fa9c7d524d72fecffad585fdd5fc6b31003a3a735d63", + "bytes": 601 + }, + { + "path": "src/ipc/media.js", + "sha256": "047b5516dd1ee49eb399ef75d9545389031e009cbd91b16defb26b115b52d388", + "bytes": 37412 + }, + { + "path": "src/ipc/pfs-ripper.js", + "sha256": "4ee5251d8c6ca43f6264f29b67fd1574a1c55dbc2f15239ddb68c07c9c235ed2", + "bytes": 7399 + }, + { + "path": "src/ipc/system-state.js", + "sha256": "aa105b04b9d7e6909389a2f35351f28ef7c8ecfca0431769e2af9243ab639296", + "bytes": 3277 + }, + { + "path": "src/ipc/trophy-history.js", + "sha256": "2a72288d108a904b32fb3574b37ab2e07ae314866b7751f6403365e95f00fd3b", + "bytes": 5556 + }, + { + "path": "src/ipc/trophy-progression.js", + "sha256": "6ddc6beb9f59b0dc0213fb071bf69d7c31a042a9c8bc7e1f95069343e0458d07", + "bytes": 1334 + }, + { + "path": "src/ipc/y2jb-generator.js", + "sha256": "97a794d0f0ccab33243401c19554300a0408009c3773dc9a197d09c680357a50", + "bytes": 2042 + }, + { + "path": "src/persistence/settings-store.js", + "sha256": "6fcfb8578daa37fa52a09bede6fd7aeb765526629c019c401f4bacfb1bfff666", + "bytes": 1889 + }, + { + "path": "src/pfs/native-pfs.js", + "sha256": "3271307d0f169b891d15e3fe6ed2c46cf4eb5654ddc0d15d2b1e6a330fafbc7d", + "bytes": 32545 + }, + { + "path": "src/platform-capabilities.js", + "sha256": "d616dd5a2c5d4bd95c2b9fb035cb2a262cbcd84057f946d47f4f7c4c2a77c3e1", + "bytes": 677 + }, + { + "path": "src/platform/build-info.js", + "sha256": "73b2cc42ee3fb0ffee2455d6969d9598b02f36d8df4c07f43761e3e54aae150d", + "bytes": 1133 + }, + { + "path": "src/platform/data-paths.js", + "sha256": "9d34d678a5122b6af075696a0037d4fea3499d49afd15e19fda5ed6fb1bd4549", + "bytes": 2755 + }, + { + "path": "src/platform/logger.js", + "sha256": "79b9ed3ddeb57cc7f117dc170124985ffc1dda6ea97075873f6d519f88bf0c6a", + "bytes": 3369 + }, + { + "path": "src/prospero.js", + "sha256": "67de5347e141d494364e3023df6cd1574af3c72243cf34ccb31d88545777206b", + "bytes": 12221 + }, + { + "path": "src/renderer/recipe-editor.css", + "sha256": "a5cf55f10743c25771da6b1ff38553cd754205f8ffb22a9defa19ab2ebb4aba6", + "bytes": 1586 + }, + { + "path": "src/renderer/recipe-editor.js", + "sha256": "564266112baea0c6394b367cdb9527b2b63a0b19eaaa580c2f8b3fc763c747c2", + "bytes": 12112 + }, + { + "path": "src/renderer/trophies.js", + "sha256": "6f12acda71e12488ef3fabe39340e1b58281d59d5d4bb30d4902726ee5eceb4f", + "bytes": 67787 + }, + { + "path": "src/security/passwords.js", + "sha256": "2bc382618269b85043cbd8ba9c51a350c88723fafb01402ea255b815567880f7", + "bytes": 1204 + }, + { + "path": "src/security/read-only-sql.js", + "sha256": "9d2593e393d6b09d2cba417aff29e4ebe5af61dd679c6968ca66c1c5904e2cdb", + "bytes": 4841 + }, + { + "path": "src/security/secrets-store.js", + "sha256": "10fb9e07926750d34d608598ac000dac675207558183dba861aa459759ec9d0b", + "bytes": 2377 + }, + { + "path": "src/security/totp.js", + "sha256": "d66b958cb534cfa19cbe6cc2232454bfb35d6bb8a942cf4d37f972916399400d", + "bytes": 4596 + }, + { + "path": "src/server/app-server.js", + "sha256": "1a6e2047657825fae7ad6734a15c9177421416de2b89730262b750e10149abc9", + "bytes": 62407 + }, + { + "path": "src/server/create-registry.js", + "sha256": "c6aaa2337ec3b999583ddc12b15ec516995b7ab2d60b5a6016489347f5a16b57", + "bytes": 197450 + }, + { + "path": "src/server/payload-manager-proxy.js", + "sha256": "5a475572315c8faa7be51620cde2fddcb8022fb2552a88cd2d8bd2fcf7edbcfe", + "bytes": 10700 + }, + { + "path": "src/server/register-functional-modules.js", + "sha256": "cbab8e90f3ab7af7820b68110b8ee6d04097aecacd931044238152e9d4e6c754", + "bytes": 84357 + }, + { + "path": "src/services/apr-ampr-adapter-service.js", + "sha256": "431ada0bfcf09d533675a917f4bd9c0a3b431708c45eaacfda257b93d17f8756", + "bytes": 12055 + }, + { + "path": "src/services/archive-worker-service.js", + "sha256": "2457fd4f79bd047f046b790410edffebf1d54dba8f8c73f0e86e1b739b433499", + "bytes": 14619 + }, + { + "path": "src/services/audit-log-service.js", + "sha256": "775df4a1c41be65d1970ae44e3597c6feeb2d41199a189f4bdcd16f50a57626c", + "bytes": 6696 + }, + { + "path": "src/services/backup-replication-service.js", + "sha256": "7c982fc493c0c0c54f144118800bf17cd1a16fc28743fdaa37c274b120d9703f", + "bytes": 18798 + }, + { + "path": "src/services/chimera-beacon-client.js", + "sha256": "f711c925c1ffb46c8a63a5843bbe7180bdf8ef3e5d1367f4d1aa95c950cae0a7", + "bytes": 11866 + }, + { + "path": "src/services/companion-worker-service.js", + "sha256": "72df6fcc94417bff9d8eb0b5b8b51b214d26e0b11cac245a70db6d10ebde6922", + "bytes": 7991 + }, + { + "path": "src/services/compatibility-guidance-service.js", + "sha256": "4725dc04c9f307ee30253dab9f1b4c83656632d3e27171cca14bb171e45159a4", + "bytes": 2100 + }, + { + "path": "src/services/compatibility-provider-service.js", + "sha256": "b0fdbdb64581267f70cdb4549adade7ada498fed12192674c0f71714efbaa6ee", + "bytes": 14541 + }, + { + "path": "src/services/console-capability-graph-service.js", + "sha256": "137e378fd25592060f08649a04ae92a710ccc16a787cd1887a19bdd87bb9cdc2", + "bytes": 7150 + }, + { + "path": "src/services/console-profile-service.js", + "sha256": "0c5f25d4f901dd820384490197dcf08c924d8884d816a86aed83695c46b06d1b", + "bytes": 14673 + }, + { + "path": "src/services/console-scan-reconciler.js", + "sha256": "2b55dc2ca4c94133e83363ae30df0d55ce4c3e3c775e2c12a73f0f59a126835d", + "bytes": 17582 + }, + { + "path": "src/services/conversion-service.js", + "sha256": "1ac2e14dce770611d9667b51d2f4612f1e980b0bfd2370727aa8d5204646212c", + "bytes": 10677 + }, + { + "path": "src/services/ecosystem-intelligence-service.js", + "sha256": "7006c810c16683116b9ccf7c337e5b73811c0324a39006081639c661f1817046", + "bytes": 42823 + }, + { + "path": "src/services/ecosystem-service.js", + "sha256": "02786761917cebe8153d96d19db8a403d2ca6c830306f9336d2896db5d9b0c90", + "bytes": 105776 + }, + { + "path": "src/services/elf-arsenal-client.js", + "sha256": "ff2dda6d79d6d722dba0678516808b72795927c16ccc219a27e5a7133d16691b", + "bytes": 3608 + }, + { + "path": "src/services/etahen-dpi-v2-package-installer.js", + "sha256": "7b5929a3908e0bb05d7439ad1105428a5b856515eece78885bd9a32bee8bc708", + "bytes": 7523 + }, + { + "path": "src/services/etahen-link-agent.js", + "sha256": "edef06133e000b3867df2c9cc0c983df59798a50be8a872b7382faa5e4c8e9eb", + "bytes": 15202 + }, + { + "path": "src/services/etahen-link-contract.js", + "sha256": "23463c6bcbc586558bde39b56fbc301c3fd32b3db61a319d0f2751410a32cb36", + "bytes": 5988 + }, + { + "path": "src/services/etahen-link-inventory-service.js", + "sha256": "1cdb758bd7260406a27bc4d2e748a8a1f4891121b9bc316e81fe98764bd06c60", + "bytes": 15522 + }, + { + "path": "src/services/etahen-link-lock.js", + "sha256": "fdb642637641ac9a56e12e999e77b40d7db79a4a74f573bb542871b335d2388b", + "bytes": 4538 + }, + { + "path": "src/services/etahen-link-operations.js", + "sha256": "c45d14aa74e030bee182516593298d17cdda7a62f8cea71b83ae6c8741f8eec6", + "bytes": 47512 + }, + { + "path": "src/services/etahen-link-orchestrator.js", + "sha256": "6e2e27d3f93ea912947a2418844c0fb278aea06b5d8ccb7d61f90b932c25f88d", + "bytes": 41230 + }, + { + "path": "src/services/etahen-link-shim-installer.js", + "sha256": "421a7a1bdce6bafd5a87b8eadc7ed591fab2063d6d435bea4be40edb913453de", + "bytes": 11180 + }, + { + "path": "src/services/ezremote-server-client.js", + "sha256": "edc2e0d855817563712d0148392f5ca5ff40290681ba02178e3bbfd0c640583a", + "bytes": 9688 + }, + { + "path": "src/services/ftp-scan-root-coverage.js", + "sha256": "e80ae9a6b61cd20aacd24ac610e54ad6bc72152699eea15d15ec8aaa2cd38192", + "bytes": 2854 + }, + { + "path": "src/services/game-metadata-service.js", + "sha256": "e6a67f5796d4a1dab7b053847219bd67be036c7bb4b524bfff8d35b754746eb5", + "bytes": 9983 + }, + { + "path": "src/services/game-metadata-view.js", + "sha256": "b500f528aab1e7b5c2eb97a0e5299e24d7481cf58595f191b0dffb6a367a374a", + "bytes": 4553 + }, + { + "path": "src/services/game-move-adapter-service.js", + "sha256": "1c24ca43919a0b1f1743da34245c57c8c571a6d6980417a7eb4513ae11e972bf", + "bytes": 6323 + }, + { + "path": "src/services/game-update-service.js", + "sha256": "6b2b36987e85444ed48c51734f070c8512e732683b7b1c58b2d50dc0a3081d92", + "bytes": 32421 + }, + { + "path": "src/services/guided-game-move-service.js", + "sha256": "ba8f477c3a3ed229c6a9d4d573056c0c322720a982907a909d27114ceed0104c", + "bytes": 7560 + }, + { + "path": "src/services/import-classifier-service.js", + "sha256": "ebc928dfc95273c67a6896ffedae94acf9b81fc8b551e674823c07da28df3cc2", + "bytes": 16063 + }, + { + "path": "src/services/import-staging-service.js", + "sha256": "516fc19c96228ab5797b62746b3023a09f744121faa70bef165615e81624bcc6", + "bytes": 16430 + }, + { + "path": "src/services/import-upload-service.js", + "sha256": "67ff3aa88fabe7aafd66358c5ac2da15f83227c8f5fb628e22741f5143a477f1", + "bytes": 12317 + }, + { + "path": "src/services/imported-trophy-definition-service.js", + "sha256": "cc493dfc4a4d6c9c207be65b318bc5258c4d3648366fc6bb9e7aa12935fb8c43", + "bytes": 5266 + }, + { + "path": "src/services/inactive-legacy-trophy-unlock-service.js", + "sha256": "cb14f2f1d930aa502e5f221f757699a3da13ee2b5018158289057429351d4eb0", + "bytes": 9475 + }, + { + "path": "src/services/install-destination-service.js", + "sha256": "aed2b2202ead9124fc734f8aca927b94c2d5d93c90af9abbe3518390c7ada5d4", + "bytes": 4099 + }, + { + "path": "src/services/install-orchestrator-service.js", + "sha256": "7370f6971938d844ee393c14110a057bb4f5b559f704094e81c7bab72d71d631", + "bytes": 12872 + }, + { + "path": "src/services/install-recipe-repository.js", + "sha256": "b23414bca537469611c6586e39a39692925f5be767b3a668ce000aefbe17af07", + "bytes": 4595 + }, + { + "path": "src/services/install-recipe-service.js", + "sha256": "dd80b99f893f15d1c3fd65807d6742e86440256bde2009e52582eb8e9310c7a1", + "bytes": 12264 + }, + { + "path": "src/services/legacy-detached-trophy-mutation-adapter.js", + "sha256": "a5640f570eb2e70c40e077d8a656efce2981eb0b62c764e3ca00a2115f8b0387", + "bytes": 3618 + }, + { + "path": "src/services/legacy-detached-trophy-preflight-service.js", + "sha256": "83bb3f806c314a3446b2d27acf01a531480a33bb4ee5ca659b7359693f9efd1b", + "bytes": 9945 + }, + { + "path": "src/services/linux-listener-process.js", + "sha256": "0f05550833780d77647d274f57bd60bbbb5a00efabf5ec88937bbbd52ae6a603", + "bytes": 20278 + }, + { + "path": "src/services/live-trophy-catalog-service.js", + "sha256": "62ff0a13eb4af02c13fe29d253dbe916db27145aa800dcbd4a279241e9dbceda", + "bytes": 14255 + }, + { + "path": "src/services/local-library-scanner.js", + "sha256": "2ec0e76fa23c966cae0120aaecfa0b86db1bdf23f27d5306d889cbab38c772bc", + "bytes": 27832 + }, + { + "path": "src/services/local-trophy-inventory-service.js", + "sha256": "fa822c9dddfacf742e183300232440cddcc2bcabfaed1cedbcd92275924eece2", + "bytes": 15902 + }, + { + "path": "src/services/media-cache-service.js", + "sha256": "67c28c311a23ce8ccf1bdcacc54b9dd97b6b414b68191def09cadfa97a753892", + "bytes": 12468 + }, + { + "path": "src/services/media-import-service.js", + "sha256": "f177ad44f615608ba0c7886abaeebcdff8fbea4075a19cc3d03e24f3140cbad0", + "bytes": 10265 + }, + { + "path": "src/services/notification-service.js", + "sha256": "caae0c00f2da502f0bb4ab85d43544d9a0ef00e2884c7bdc1eed4604b83f2844", + "bytes": 4991 + }, + { + "path": "src/services/operations-service.js", + "sha256": "a1a94abce587573ae13ee1b2bda11df8815109821a44098017ab51ec036432ae", + "bytes": 58674 + }, + { + "path": "src/services/patch-bundle-service.js", + "sha256": "8819d77d26952eec9f93593a9f77be4af2dea9049d8a9a887f46fb416fa32fdd", + "bytes": 17532 + }, + { + "path": "src/services/payload-catalog-service.js", + "sha256": "59e254ee591f93133591b1ede595f1a2c318e572a8855b2e7ca24e560272b7c8", + "bytes": 3994 + }, + { + "path": "src/services/payload-manager-autoload-repair-service.js", + "sha256": "eeff531f8572340f1889666623c544b1f8d21b5f9d2178cf216c1c038a245dcd", + "bytes": 32632 + }, + { + "path": "src/services/payload-runtime-service.js", + "sha256": "2f5959518e2cfff810395d0e4bca66074296288b70efb84e5a38ab1ced31a10a", + "bytes": 14316 + }, + { + "path": "src/services/pfs-conversion-worker.js", + "sha256": "530388251e66dadaad87850d3f4608c9352c6138cd21030df5b823ea94ee81ac", + "bytes": 630 + }, + { + "path": "src/services/platform-artifact-staging-service.js", + "sha256": "9df6c98f9cabff8ef1b6f1c5a1e1a7acdd3d1297747be549be24e5f99f04bff7", + "bytes": 9739 + }, + { + "path": "src/services/platform-control-center-service.js", + "sha256": "6df997e4c2b1289966a560eb7f5796762f020925e3f697071013a49f6fb2462e", + "bytes": 5409 + }, + { + "path": "src/services/platform-operation-service.js", + "sha256": "2705440da162735c516f31974ad9ab2a4b03eb8a0a6768800678cd31312780df", + "bytes": 2254 + }, + { + "path": "src/services/portable-manifest-service.js", + "sha256": "d52a9ef42991db0ac9473998bf1d26dbc5906c5e74b0663505dec7a05ebda092", + "bytes": 13869 + }, + { + "path": "src/services/portrait-cover-provider.js", + "sha256": "8b97baefe04339a399be4e1dc7e05b28f4cedc57673ef6c3ad0cf0efe804c6d7", + "bytes": 7421 + }, + { + "path": "src/services/post-install-verification-service.js", + "sha256": "8a384f16594792584ccd060b2e5aa7830515be5f19377abe6c263cd5ca4c560e", + "bytes": 5986 + }, + { + "path": "src/services/ps4-package-backup-service.js", + "sha256": "2d2ea083961d97fba72e4bc919ad54b66e67d150fda0087608d7c46ecaad8d2b", + "bytes": 14787 + }, + { + "path": "src/services/ps4-package-registration-service.js", + "sha256": "7df36e5ad37f2071bdc44d860f6c898ef26894e9cfa844cd8651e7d46b521357", + "bytes": 3759 + }, + { + "path": "src/services/ps4-pkg-builder-provider.js", + "sha256": "017cf20035f19d4aed612c8f2346e285094bf50b3282953d8741a34c4f1a6720", + "bytes": 4297 + }, + { + "path": "src/services/ps4-pkg-installer-adapter.js", + "sha256": "4f93e582be464e02b96c43fa7647df541a35db1c29c6760f037e7b452a51265c", + "bytes": 9628 + }, + { + "path": "src/services/ps5-console-staging-service.js", + "sha256": "b9438cdb919f5f8454acf909c1582f9ae0aef733ac0fa1424f822e2d33b40df5", + "bytes": 18025 + }, + { + "path": "src/services/ps5-content-adapter-service.js", + "sha256": "6b599efc4888ba3fbc58753df904d9d925df03bcf38557899e6c012d43c7707c", + "bytes": 12977 + }, + { + "path": "src/services/ps5-fih-validator.js", + "sha256": "e6efbde4b65282cc2cbeb5be240e674f9fec57f664db574664f47143e27dfd31", + "bytes": 12903 + }, + { + "path": "src/services/ps5-fpkg-a8-control-qualification.js", + "sha256": "404a9c0bb442446853a6e18ffbcc28c277b64e941bf5b7202dca9c07b42760b1", + "bytes": 10046 + }, + { + "path": "src/services/ps5-fpkg-a8-experiment-ledger.js", + "sha256": "7c80a9f6c18c76bc17a9fcbb6c4dfe663cdad66f25306541936a311aa1fdc3d3", + "bytes": 9480 + }, + { + "path": "src/services/ps5-fpkg-compatibility-profile-service.js", + "sha256": "39a8eb7556449081cf8ca89e4cab84e36791d94590ef3d6ea50c6bc3224b278e", + "bytes": 13621 + }, + { + "path": "src/services/ps5-fpkg-completion-trace.js", + "sha256": "6fb59578a045ca115138d6f17dabde9d9a46ffe935053e22d2fbcc50bc090017", + "bytes": 16648 + }, + { + "path": "src/services/ps5-fpkg-content-auth-trace.js", + "sha256": "cf6414a57ac4251f64bc1aa402e6d35b0ce61108892eb1d95254be002cea7c7e", + "bytes": 16462 + }, + { + "path": "src/services/ps5-fpkg-control-candidate-service.js", + "sha256": "d0835f94bed2a7f1a2992c740d99852d909c0e9ff670db9867bba7bb34b8f7ca", + "bytes": 11522 + }, + { + "path": "src/services/ps5-fpkg-control-metadata-assessment.js", + "sha256": "875d29c49e9745a94a33dc46dc6c7aee81cd1c2a5d430702faade34ed3a91b35", + "bytes": 7069 + }, + { + "path": "src/services/ps5-fpkg-control-taxonomy.js", + "sha256": "9c6025c93f1be866ab83e73e0ac8aeb0b3e2752cf8adafaf6ee6a1b6b2441590", + "bytes": 6354 + }, + { + "path": "src/services/ps5-fpkg-distinct-control-install-gate.js", + "sha256": "ce327704724696a7242dc8c180bd2c1b2cc5355f067f787036b077a70d3e8827", + "bytes": 12645 + }, + { + "path": "src/services/ps5-fpkg-distinct-control-package-service.js", + "sha256": "68926baf1d42276a2da6361bac678703b3798dfd2d3bb250dc9bdc1b7507e388", + "bytes": 16127 + }, + { + "path": "src/services/ps5-fpkg-kstuff-runtime-identity.js", + "sha256": "02eec96f50f74862a7ad1d281869f4591d33ecdf82f67baff41e2365ae608179", + "bytes": 6090 + }, + { + "path": "src/services/ps5-fpkg-readiness-service.js", + "sha256": "127f579155b78335dd43fdf249d0042b2d50c88da0821374a3a53a909aa19139", + "bytes": 13910 + }, + { + "path": "src/services/ps5-fpkg-route-selection-trace.js", + "sha256": "bab973220f8d59e7667aa40ce9dd7babab17c0ad5f7ab63821446fd1410494cb", + "bytes": 15092 + }, + { + "path": "src/services/ps5-klog-service.js", + "sha256": "8575a9a4fee86364739f418dd16da8e72cac7f212d2859efce53611561d2f6fc", + "bytes": 14122 + }, + { + "path": "src/services/ps5-launch-reference-package-service.js", + "sha256": "5e5b05d272106943d68dffeef434915a16fb7661016d9d2a3f3d448aeffa57ea", + "bytes": 7234 + }, + { + "path": "src/services/ps5-native-package-install-service.js", + "sha256": "c06f21d2933cdbb4115478a73ddbeb867e802f4fbfcf16c5b8686860ea2ff69e", + "bytes": 42563 + }, + { + "path": "src/services/ps5-native-package-registration-service.js", + "sha256": "41307b2629dc2e169459d8e784bbfa2181b4fd77ebff79a53948153b7c70e080", + "bytes": 10082 + }, + { + "path": "src/services/ps5-native-package-service.js", + "sha256": "0ea0e1daf4153d74db24f1fb245b7d60792e48037eeab9d3aa11113061293730", + "bytes": 29858 + }, + { + "path": "src/services/ps5-native-pkg-builder-provider.js", + "sha256": "1916a024e1c9ad6aa507adeee897a9fa1cf38f29aa3397ab1fcce98fb2377601", + "bytes": 22845 + }, + { + "path": "src/services/ps5-p15-a10-runtime-profile.js", + "sha256": "33dddc5e695403796eda7d36c9e4716d321e151b0a7bd613b94d9fdd25c3989a", + "bytes": 1225 + }, + { + "path": "src/services/ps5-p15-a10-runtime-stage-service.js", + "sha256": "86d04483eba65aa1bbf1cc412dd9afd5503476055d9fce58ff9420db62e67e58", + "bytes": 15424 + }, + { + "path": "src/services/ps5-p15-r1-profile.js", + "sha256": "1470161434fc7420f736262e69888ecbb9c1e29371cfba2bf6e25991710db084", + "bytes": 8246 + }, + { + "path": "src/services/ps5-p15-r2-installed-content-verifier.js", + "sha256": "caaf5a22354ee330205ff93f525375191e7f1f54431f42d0c816f2bc1345ed7d", + "bytes": 7132 + }, + { + "path": "src/services/ps5-p15-r2-qualification-service.js", + "sha256": "e2ebe53cc00b269a66e3a4a213fd70f77ee5745055af923a6c3e7e6ea279bd64", + "bytes": 9470 + }, + { + "path": "src/services/ps5-p15-r2-qualification.js", + "sha256": "212bc4c9e1629507c96ac649fe987118352a214632c720ed030ef6eb65d27168", + "bytes": 18589 + }, + { + "path": "src/services/ps5-p15-r3-host-artifact.js", + "sha256": "a5be890464344f9236dc11893c645b05195a4aa6e12da26173bf46f8705640fd", + "bytes": 5131 + }, + { + "path": "src/services/ps5-package-comparison-service.js", + "sha256": "5eb80a64c0481c569448034b2b6a17117d8fc84567201cc679560067e1b6128b", + "bytes": 5931 + }, + { + "path": "src/services/ps5-payload-manager-client.js", + "sha256": "6bd201582eee9785932cc1c27a1b4fd7f1d7c282c738668ff2a7ce5deff64db4", + "bytes": 11176 + }, + { + "path": "src/services/ps5-profile-management-service.js", + "sha256": "bff6d645087773b580491a869d33176957820155a47ba49503917697464bf45c", + "bytes": 14031 + }, + { + "path": "src/services/ps5-shellui-launch-observation.js", + "sha256": "061ef5b79916d844355708038ee9c81dc464902710f1ec93ce559a41c1b807e6", + "bytes": 13386 + }, + { + "path": "src/services/ps5-shellui-transition-observation.js", + "sha256": "579adce837d9eb86b390aaf9bdf0b8459e2ac907b1c6e83e1fb4a646529199eb", + "bytes": 15880 + }, + { + "path": "src/services/ps5-upstream-control-package-service.js", + "sha256": "968d216ad032db7ad5235cdea6f611cc393c4884fc070993894b2483dff522f0", + "bytes": 7823 + }, + { + "path": "src/services/ps5debug-readonly-client.js", + "sha256": "4b26e716df393d46899121f1784a976fc0d34aa26186cec56f205f2ed737fcfa", + "bytes": 6504 + }, + { + "path": "src/services/ps5upload-package-installer-provider.js", + "sha256": "7cb940e7e53efcb295b7b1512cc9f180951b97f6e69c63f0580d0bc689dfd742", + "bytes": 51072 + }, + { + "path": "src/services/ps5upload-profile-provider.js", + "sha256": "190456689c91a5a18b05600fa13c6a5372176079d240e8d7721e227c182c79be", + "bytes": 7886 + }, + { + "path": "src/services/ps5upload-runtime-identity.js", + "sha256": "c73a27846e1e86c179fd09fc38db726490c7d4a89712a11dab2da868d3afb106", + "bytes": 6432 + }, + { + "path": "src/services/ps5upload-runtime-service.js", + "sha256": "06b6726a2ef1753be278b4391deee6f058f6e6fca5a02d5120c035cfb74b70de", + "bytes": 34761 + }, + { + "path": "src/services/psn-trophy-history-import-service.js", + "sha256": "e35241206ad8e9ec0f46d507615806b1006dbd132f30149e0d940ce71840cf5a", + "bytes": 13765 + }, + { + "path": "src/services/remote-play-intelligence-service.js", + "sha256": "aca798a313a982aea91dd0bc4addff27e0e745ee7cb535ee3d6ac7fb91da787b", + "bytes": 2433 + }, + { + "path": "src/services/remote-play-service.js", + "sha256": "6aae6bc7883a1500d8e04566f5367bdc8b519d80590b603bff09cc28be5751fc", + "bytes": 31041 + }, + { + "path": "src/services/save-recovery-plan-service.js", + "sha256": "dce7cbd86d5d17f1309e2b1dd6c3feb980ec1c1dd7975da341433146dcb77626", + "bytes": 16328 + }, + { + "path": "src/services/semantic-evidence-service.js", + "sha256": "5f96bf9d7cc4706017b6c5ba8ef7f168c17308efa286a0023a92894a20404021", + "bytes": 3460 + }, + { + "path": "src/services/shadps4-launch-adapter-service.js", + "sha256": "93ea0d5b0ec606de91a2eb483695f801b669ba2acecc2019af4de8b636146230", + "bytes": 5369 + }, + { + "path": "src/services/shadps4-library-service.js", + "sha256": "5b681f426ba53fbcf86d3b6c59b9aaaa7999d0827535dfb1fa661f7306572692", + "bytes": 5923 + }, + { + "path": "src/services/source-classification-service.js", + "sha256": "fd8b3b5f713a3f442aae81cd5bc4e3dd826f87b2936e06e72ad3525a3511e812", + "bytes": 3520 + }, + { + "path": "src/services/storage-inventory-service.js", + "sha256": "39e2f6806ffda1859601bdf555d5d00acadbe9d72d316b4c1ee1279cc439c1ab", + "bytes": 15634 + }, + { + "path": "src/services/system-backup-recovery-lab-service.js", + "sha256": "2650734f0bce741850ccff6fa5f96fb7c2fddc9a8facb7efbe9136cd7efbbb92", + "bytes": 11688 + }, + { + "path": "src/services/transfer-service.js", + "sha256": "af3fb728f85927e2086dbcb6cdfa88ba40590222286ef44a3d1d481611726fee", + "bytes": 18470 + }, + { + "path": "src/services/trophy-mapping-diff-service.js", + "sha256": "a214bc17f82ce213654087c3a16bb43267feb30fdcee80f386ee856b44a1e68e", + "bytes": 10532 + }, + { + "path": "src/services/trophy-progression-planner-service.js", + "sha256": "fb44b307ccb04a250885758deb7610b6387921c2ad9b8d5b302a7ca3a3690955", + "bytes": 16112 + }, + { + "path": "src/services/trophy-reconstruction-action-service.js", + "sha256": "24379cb94a42e39fa2287dd706272d2d9fb226d7f658e564c4f6bad7f93b3773", + "bytes": 18665 + }, + { + "path": "src/services/trophy-reconstruction-plan-service.js", + "sha256": "62378736cc7f60b933df1ecd4b0795cb15da8865efe4dff2504480b132fa6bf0", + "bytes": 12002 + }, + { + "path": "src/services/trophy-snapshot-service.js", + "sha256": "41d221961b7f601ae0833a6760e4398af02e7dc3b44fa6f32479907bc51e2b9e", + "bytes": 19946 + }, + { + "path": "src/services/unified-autoloader-service.js", + "sha256": "ac71dc9bfbcb5ee5265fa6f183e1d23e00ef96aa0ed29be4a5878ba1ae73a86c", + "bytes": 9651 + }, + { + "path": "src/services/update-lifecycle-service.js", + "sha256": "dff4ca6864c34c39647268ea58de11cf04a388fbe48db4a724669a149d0b3d1b", + "bytes": 12133 + }, + { + "path": "src/services/upstream-binding-registry-service.js", + "sha256": "8badb0790a4754b5aeb531c43b95375e4f2185f5285090a7549c03c54bfde3e9", + "bytes": 7761 + }, + { + "path": "src/services/v3-derived-views.js", + "sha256": "e11dce2d37ab0b12e50144f9363426392cc71369b1d5baa3c645accf845c2fdb", + "bytes": 6511 + }, + { + "path": "src/services/v3-library-service.js", + "sha256": "f9153ef3ef73bd8b8f591fa895989fb7820f98a5eb53466449793c4aef9bf357", + "bytes": 15600 + }, + { + "path": "src/services/v3-repositories.js", + "sha256": "45d445bd906d4bccaed88f09e702cafb8cb0db377b391f23816252a18bebe8e7", + "bytes": 27564 + }, + { + "path": "src/services/websrv-discovery-service.js", + "sha256": "9f02eb23a7b25a6d36b3ff32701c1e3a4cbcf7231fa3e4aeb181f3d9ec54071d", + "bytes": 2526 + }, + { + "path": "src/system-state-client.js", + "sha256": "573cbcf0f6bbad2cd570d461115bc5b060412a30953c7b5bb40aef36062148f0", + "bytes": 815 + }, + { + "path": "src/ufs2ElectronApp/UFS2imageexfatTools/New-OsfExfatImage.ps1", + "sha256": "5d79844d3691cdc49cd1351b360eee327884e809b0af86bea4a7753f74f8f589", + "bytes": 14751 + }, + { + "path": "src/ufs2ElectronApp/UFS2imageexfatTools/make_image.bat", + "sha256": "d4592ec5a85b606a9080866d23c43fc7c1960d6e2140c9344b65167c30e23466", + "bytes": 1288 + }, + { + "path": "src/ufs2ElectronApp/main.js", + "sha256": "e53061619519cd69b868bf71eb839fe734a0bc99037ca377fdf2bddf3b41fc3d", + "bytes": 40226 + }, + { + "path": "src/ufs2ElectronApp/package-lock.json", + "sha256": "56f460054d87f342113bfa06576b7d2c1228ee2cc6428493e3257d1ba2518c26", + "bytes": 193177 + }, + { + "path": "src/ufs2ElectronApp/package.json", + "sha256": "2a9bedc8c4537f330a4ba2cd8a8c877fd111c69fda1565c8fb1498ac3bd9798c", + "bytes": 1408 + }, + { + "path": "src/ufs2ElectronApp/preload.js", + "sha256": "1fa8f9535b38609d2cb8eea2db6a259fe8b26243aad35cfd87ece434958171d6", + "bytes": 4499 + }, + { + "path": "src/ufs2ElectronApp/renderer/app.js", + "sha256": "9fa884efff18529456a853fa3e8f6f4926f781d313694701675c22d757bb9a2b", + "bytes": 39912 + }, + { + "path": "src/ufs2ElectronApp/renderer/index.html", + "sha256": "c199d6902368b8d617523fcb238f385332e8fd991be7f8ef9d3c2fc596e1021a", + "bytes": 17454 + }, + { + "path": "src/ufs2ElectronApp/renderer/style.css", + "sha256": "edcb6c2b5ef41c5240c682d5db6201a45292b8006abc91c4f5115f90de72fb51", + "bytes": 12607 + }, + { + "path": "src/ufs2ElectronModule/README.md", + "sha256": "6d73e3eb38e0b6903c06d3031bbbdf124c5a5e5c0bb0cae9e018b6dc686e1969", + "bytes": 8045 + }, + { + "path": "src/ufs2ElectronModule/index.js", + "sha256": "712599ecb6e29c85e41d8ac9669de566a39535511d47c50a3bf6370d65c055de", + "bytes": 9982 + }, + { + "path": "src/ufs2ElectronModule/package.json", + "sha256": "4e64ceae6d072a1a4d13f582c7fd3afecac5b0e4701954714d0b19a58f9c2c46", + "bytes": 966 + }, + { + "path": "src/ufs2ElectronModule/preload.js", + "sha256": "9a8adbdfb4bea84013ecf34d268f9c61ae28746c8162929c41ea3810ab53fbdf", + "bytes": 9690 + }, + { + "path": "src/ufs2ElectronModule/renderer/ufs2Renderer.js", + "sha256": "c44866cbb71b8e8496916bdc5bf0d8ddf383253695f3b598b647fa262f1675de", + "bytes": 5304 + }, + { + "path": "src/ufs2ElectronModule/src/ipcHandlers.js", + "sha256": "11d85f9038f25f11cf49e21f5fce840b4f06c95bcfbc462b4c35fc8d127483fc", + "bytes": 16154 + }, + { + "path": "src/ufs2ElectronModule/src/ufs2Core.js", + "sha256": "1635c00b9606efbc9f969e270bc2ecb5a481a25fc21d7ded86bbf1d19864918e", + "bytes": 33739 + }, + { + "path": "src/ufs2ElectronModule/test-smoke.js", + "sha256": "c99ac641bc6e18421095ed6931f4608e7f78f957b001fe436753f6e56090eb9f", + "bytes": 1128 + }, + { + "path": "src/ufs2ElectronModule/test.bat", + "sha256": "cfc931929d4d9c984d0a35ef01d66915345cb37cab1a7b65c13704620cde16ac", + "bytes": 5217 + }, + { + "path": "src/ufs2ElectronModule/test.js", + "sha256": "11ff1e37f5352de234b504afa162e7ffdcc0eb4f1e114b9d7c11e089ac085861", + "bytes": 12480 + }, + { + "path": "src/web-server.js", + "sha256": "b3c4c7d5f38d19ad1890ae2f032cba5e5ddd9aed1740f4e47a905c1d0bf01d75", + "bytes": 15218 + }, + { + "path": "src/xavatarElectronModule/README.md", + "sha256": "0eac52432f6ab083c89e58f9958ce7c5463253e548e8d8979de33804a922e5fa", + "bytes": 7550 + }, + { + "path": "src/xavatarElectronModule/examples/basic-usage.js", + "sha256": "7dd0791377eb7a8abe4e4c43646dadd5da3bc06e1413539c9d62986e42d4d9b3", + "bytes": 2035 + }, + { + "path": "src/xavatarElectronModule/examples/electron-integration/index.html", + "sha256": "f9bac0b75548761149ae49cfb5bcfbe2c261d5c3eaf6f8500a95bf5ae4992da3", + "bytes": 8318 + }, + { + "path": "src/xavatarElectronModule/examples/electron-integration/main.js", + "sha256": "222bda51c3c2217dfd488a6d05f02a701005d37c88a5770d04837dbcc7bd7f60", + "bytes": 2002 + }, + { + "path": "src/xavatarElectronModule/examples/electron-integration/package.json", + "sha256": "fd14ff987db1be23fc80cffef2ec489d877c8fe66ad85471511fb9b4ee1476e4", + "bytes": 390 + }, + { + "path": "src/xavatarElectronModule/examples/test-avatar.xavatar", + "sha256": "6af18b1745189e14bc0abb706d0c71fcfbaa98c25aab886324cfb1422685ce87", + "bytes": 3358 + }, + { + "path": "src/xavatarElectronModule/index.js", + "sha256": "a40989dc7eebe35b5e9d7099abb73b43690fb87bd6359a4f4eb25798d1de15de", + "bytes": 6179 + }, + { + "path": "src/xavatarElectronModule/package-lock.json", + "sha256": "9f254e38c5e7fae09b9bba53570b99e2864c7c0e14074baae2b9984320347bc1", + "bytes": 53979 + }, + { + "path": "src/xavatarElectronModule/package.json", + "sha256": "6bf2fe50e05fb5ef20d909554f7b2aa121ed4aa5add4ec10f27103039787598a", + "bytes": 1033 + }, + { + "path": "src/xavatarElectronModule/preload.js", + "sha256": "924b516c10ed0eff6a07387fce495fed89ae2c056c2a722d4356e2e4663aeff8", + "bytes": 5849 + }, + { + "path": "src/xavatarElectronModule/renderer/xavatarRenderer.js", + "sha256": "685fb01b1ad5bcd0b501d3e26d10a3fba5df5776d844e6047eb4e1608e812cbd", + "bytes": 10875 + }, + { + "path": "src/xavatarElectronModule/src/dxt5Encoder.js", + "sha256": "9bbec5fcfa5dab6682ad239ebca26b0d11064057839345138b24a597a99ed3c6", + "bytes": 9567 + }, + { + "path": "src/xavatarElectronModule/src/ipcHandlers.js", + "sha256": "40d14c9c4bd2e204212e21691726e91d19c2a801a053fe22222a4dcda76f6f8b", + "bytes": 5137 + }, + { + "path": "src/xavatarElectronModule/src/xavatarCore.js", + "sha256": "bf99102b8ab4f9c8a9f1fc063e8102b753350e0444b6d4874cf1bb5578481e68", + "bytes": 10995 + }, + { + "path": "src/xavatarElectronModule/test/basic.js", + "sha256": "40117bc58b9546d17b7c82e6b70d836481aeed21579dd72efe49fa4dd375f1bf", + "bytes": 1259 + }, + { + "path": "src/y2jb-generator.js", + "sha256": "1d14e63f8b2f72f3ca1ba41bf94c1901d78c7afaef96cca0585829b1b2b25181", + "bytes": 9397 + }, + { + "path": "test/autonomous/build-kstuff-diagnostic-loader.test.mjs", + "sha256": "a69aec867548858358a55c57e47591517ca6f66cec309d23e66319c18d97900e", + "bytes": 2282 + }, + { + "path": "test/autonomous/inspect-ps5-fih-route.test.mjs", + "sha256": "a8c2cf24b4d943b915def3a188c8b2f56f992caf0ba19f8aede23247ab38e123", + "bytes": 3877 + }, + { + "path": "test/autonomous/parse-kstuff-mailbox-capture.test.mjs", + "sha256": "91790055f85b199571f39984da04faa4e8881c60a0e31202700e36e8dd0fcfd7", + "bytes": 2776 + }, + { + "path": "test/baseline-fixture-harness.test.js", + "sha256": "30a7940cbf2a0033c9f2ad07b1bc79cf61b645bbcf6a78829a5ca60579813ba8", + "bytes": 2257 + }, + { + "path": "test/bdjb-generator.test.js", + "sha256": "28f9a8da3c6178a85585241d8e18dc25f998031840dc1345ed51d2a9e381fb4f", + "bytes": 2210 + }, + { + "path": "test/fixtures/ch-1204-etahen-link-ui.html", + "sha256": "fd583a2221200efd8f2caac9e7cd707c69c53c934afcec88438a2060f7158db3", + "bytes": 946 + }, + { + "path": "test/fixtures/chimera-baseline-fixtures.json", + "sha256": "992ca1959ee0f16e05a5bb00697dcfc3af6d97fdc1a057bf4c8464e3b9218b4d", + "bytes": 3206 + }, + { + "path": "test/fixtures/ps5-data-dlc/data/owned-content.txt", + "sha256": "3e403a0fc594b90599e11898e669e71508ab34528ad4af03891e2f71068d41a2", + "bytes": 64 + }, + { + "path": "test/fixtures/ps5-data-dlc/sce_sys/param.json", + "sha256": "abd882c29fe67a514deff26aa2fdb1a66621dd14ee60ce9f2f101f8bc27f5f0e", + "bytes": 315 + }, + { + "path": "test/navigation.test.js", + "sha256": "ade8063030efec4c0d23e557e4e1e748dbf25ea0e94e285d22f98fa615b24eeb", + "bytes": 24848 + }, + { + "path": "test/platform-capabilities.test.js", + "sha256": "f35091e5f5a049ec8821373012bce163ed845c39393d71ae102cdbcdc5c19292", + "bytes": 609 + }, + { + "path": "test/server/app-server.test.js", + "sha256": "363234de5f95e344b66148b2fa08e1df124ba5880c071a56b3096436a03d78a1", + "bytes": 22928 + }, + { + "path": "test/server/audit-log-service.test.js", + "sha256": "8fc713431c26b134a93a4c0175ef343b159849572f414f3e8d21b781b6aad452", + "bytes": 3313 + }, + { + "path": "test/server/backup-replication-service.test.js", + "sha256": "ba51c742ba275e1b6938d07ed2e2268aa97b4fc40e66bcd90c44631dec24c7ba", + "bytes": 4536 + }, + { + "path": "test/server/build-info.test.js", + "sha256": "439412df685973384a2b2c9b6b6d0f0d99aeb80c2794c2ce3e073496ce8be6a1", + "bytes": 1740 + }, + { + "path": "test/server/ch-0003-registry-queue-completion.test.js", + "sha256": "0a25c8e0cf1714443eb94e221638b525e0f4b55da9b8eeb2d2bc8d8226b0d439", + "bytes": 15308 + }, + { + "path": "test/server/ch-0107-library-contract.test.js", + "sha256": "b15532bf5aa6e50f73ccbe4f76abe81c695b73188f9ceea501ce7befc4414b87", + "bytes": 5136 + }, + { + "path": "test/server/ch-0108-legacy-write-guard.test.js", + "sha256": "74c9f65e13f0ec8d32806fe4cfb7d9360a0bda343db4e4a247992ecbbae32e83", + "bytes": 1737 + }, + { + "path": "test/server/ch-0109-migration-gate.test.js", + "sha256": "821c9a161de9fd80f3acae1c68d189031cb49845cd3bcb576b00efcf04183a06", + "bytes": 1080 + }, + { + "path": "test/server/ch-0201-title-workspace.test.js", + "sha256": "defddcf0b3ff614c7f6295f4195dd309ee6a4633917cdaa56d1622cf67477854", + "bytes": 2999 + }, + { + "path": "test/server/ch-0202-protection-policy.test.js", + "sha256": "645c2fb5c3b014d396e0de66a44c4b337ef1e09e56b800fd312bc5b5c75d1283", + "bytes": 2588 + }, + { + "path": "test/server/ch-0203-storage-inventory.test.js", + "sha256": "0e67007e701afd583f7bb2a36745d210c19abfea169437e91efcc7e8c11b1bc1", + "bytes": 4771 + }, + { + "path": "test/server/ch-0204-install-destination.test.js", + "sha256": "b9b9663c36ec1e0b062297e7419f78fa4109d3c466312cda58d62180bb138415", + "bytes": 3784 + }, + { + "path": "test/server/ch-0205-drive-locator.test.js", + "sha256": "57d403f3bfbb486eb389035366e599a63228bb9833eb0bc2fea06662ff9a757b", + "bytes": 3662 + }, + { + "path": "test/server/ch-0206-guided-move.test.js", + "sha256": "f4f20e8e05b8df2cca82ab3ceccda74aa8c74724e818df0b40c2cd095dcfd480", + "bytes": 4690 + }, + { + "path": "test/server/ch-0207-game-move-adapter.test.js", + "sha256": "45f15d84c573077b5aad5dba581994f6216cdfab66c2d362e8c5e56c6b112855", + "bytes": 5024 + }, + { + "path": "test/server/ch-0301-import-upload-http.test.js", + "sha256": "270a4a9809048de3cf10377c965bc0e5da93ff9765f78d4f00d166e09ac3c0fe", + "bytes": 3720 + }, + { + "path": "test/server/ch-0301-import-upload-service.test.js", + "sha256": "7b504ed00e786a2f63694be1b948461ea608ef691dfc9badac357906d294df8c", + "bytes": 5174 + }, + { + "path": "test/server/ch-0302-import-staging.test.js", + "sha256": "25bc90865c812a9b6cb9eda2624e75358dd0640cca9d8a275d5e53f828d28471", + "bytes": 6718 + }, + { + "path": "test/server/ch-0303-archive-worker.test.js", + "sha256": "6fd5a66420c3cd8181f6112bb5420f5571a5f9c66b3453dcfeaf9c0e770e7ec7", + "bytes": 6695 + }, + { + "path": "test/server/ch-0304-import-classifier.test.js", + "sha256": "0b684c065337ef7bbf249ca7dd6940e92f45e982f838a8e5df82865354548a45", + "bytes": 6351 + }, + { + "path": "test/server/ch-0305-portable-manifest.test.js", + "sha256": "e3244298346023f4ded52a642d3fcd8cfbfd8cc75e2b81f009ed231b06ca3047", + "bytes": 5361 + }, + { + "path": "test/server/ch-0306-import-wizard.test.js", + "sha256": "182f10ebefcfb303e06e533ffca0fe5847e04167d2f8c42b2f1b3251b83ae447", + "bytes": 5130 + }, + { + "path": "test/server/ch-0401-package-set-persistence.test.js", + "sha256": "8d24c868e6f5c4c1248fd2d3118c28bce5b071ed69c9fa8018eaf3500047ef17", + "bytes": 5992 + }, + { + "path": "test/server/ch-0402-install-recipe-validation.test.js", + "sha256": "7883419503ca8c3a14f97fcb02c23e3c458a781431525e14bc41009c99ae7fe8", + "bytes": 6172 + }, + { + "path": "test/server/ch-0403-recipe-editor.test.js", + "sha256": "3e731e3c4dd364eca1330cb7da713c01eba9cfcf274a2230d87dd828a814f9af", + "bytes": 4478 + }, + { + "path": "test/server/ch-0404-install-orchestrator.test.js", + "sha256": "4f65f4ae4754dcca2280b76cd5d92cc57728f7f87c3af0c762562f3fa2865bbe", + "bytes": 7010 + }, + { + "path": "test/server/ch-0405-ps4-pkg-installer-adapter.test.js", + "sha256": "bc2da89bbcb598f3f35d10630dec4f1fffbda009427405b39ddc1e1e7ff2d645", + "bytes": 6191 + }, + { + "path": "test/server/ch-0406-ps5-content-adapters.test.js", + "sha256": "4faad08c3935bf66f645bf1787695eefcf971bf2e3423dc495303228c8f39297", + "bytes": 5525 + }, + { + "path": "test/server/ch-0407-post-install-verification.test.js", + "sha256": "beaee46e7aca2cb9d999540b2d2ad50a49a1f28b0f1a73ebc679c4031b8f6386", + "bytes": 6599 + }, + { + "path": "test/server/ch-0501-game-update-evidence.test.js", + "sha256": "bc8d74d63e74898dc9a7f6db15fbdeaf41641cbe78f82110b948e1407b0b893b", + "bytes": 4126 + }, + { + "path": "test/server/ch-0502-update-lifecycle.test.js", + "sha256": "06bd589d521369e51e8927eb16e249560d7e875045d9e7752f2abf1630447c0c", + "bytes": 5915 + }, + { + "path": "test/server/ch-0504-package-library.test.js", + "sha256": "ac91a6d6778c6624175700bc090fa8045175baa23bc64b789475f719ae0d7d83", + "bytes": 4448 + }, + { + "path": "test/server/ch-0601-compatibility-provider.test.js", + "sha256": "52d1a92e6d05bfb4209d591c89fb77068ea9f9f8fc4ab544d2f7f93fe766f48c", + "bytes": 4875 + }, + { + "path": "test/server/ch-0602-compatibility-guidance.test.js", + "sha256": "c6bbd2c934e1540ec30ae891ba0f62891bbd000232ee0eba747eb7120574fc39", + "bytes": 3998 + }, + { + "path": "test/server/ch-0603-patch-bundle.test.js", + "sha256": "5a0c80e560f49471641c1682958ff9c6c52439e7e30939e6bdecbd6954bb7caf", + "bytes": 6220 + }, + { + "path": "test/server/ch-0604-apr-ampr-adapter.test.js", + "sha256": "60e63769fcee799dfe867ebe49ad7d8789d6e14d5b16d6b84ba25c45fd12fa3d", + "bytes": 5263 + }, + { + "path": "test/server/ch-0701-media-cache.test.js", + "sha256": "17487aab188deff90be9bb854bd5ac9e432e2150cb438e5b23de6b845045966a", + "bytes": 4589 + }, + { + "path": "test/server/ch-0702-media-import.test.js", + "sha256": "627df281e40a57cbfcb050b1b2903ca31358d0c2f2602a55597f5ca094e8418f", + "bytes": 8501 + }, + { + "path": "test/server/ch-0703-expert-navigation.test.js", + "sha256": "1089949cd11b80d6da1c539b0531beffa3974fc26ca6d99ef40a557035d37b2e", + "bytes": 3524 + }, + { + "path": "test/server/ch-0704-settings-identity.test.js", + "sha256": "43c2d1b5603e0306c97f66c228147b4be1625e23f3b2717eb8dd2a1a404967c7", + "bytes": 3216 + }, + { + "path": "test/server/ch-0705-p7-gate.test.js", + "sha256": "77b164020d3508105c29fd4e221fad004b2f56cf1b80192a545d94cb0af037dc", + "bytes": 1761 + }, + { + "path": "test/server/ch-0901-trophy-snapshots.test.js", + "sha256": "fdb4f1615bd694d4f789a960c79e3ec8c4f85f02131c8ec9e1548015e210dc38", + "bytes": 7711 + }, + { + "path": "test/server/ch-0902-local-trophy-inventory.test.js", + "sha256": "8d9d735d90a393ad4f9393e0daabf1b0b290130d06bcce5516fc51ecaba988b9", + "bytes": 5776 + }, + { + "path": "test/server/ch-0903-trophy-mapping-diff.test.js", + "sha256": "9188b8ec826aff65653485a429d63a82db89192e6b1471f1dcdd3000fa98c70c", + "bytes": 9083 + }, + { + "path": "test/server/ch-0904-trophy-reconstruction-plan.test.js", + "sha256": "2cef0f8e116a50b10cc0ccf0a63ff98a507b3a5732b1f81e98b6b050790813c3", + "bytes": 7377 + }, + { + "path": "test/server/ch-0905-trophy-action.test.js", + "sha256": "863da5c296ad332ac789d660f499db57b01431d156a19a597d4b48a04d312b4d", + "bytes": 7571 + }, + { + "path": "test/server/ch-0906-trophy-ui.test.js", + "sha256": "4ca57acabbaf44656f1f612555ac1e1e521fb30e75cc17d5e6c6f62ca15b2fc5", + "bytes": 9385 + }, + { + "path": "test/server/ch-0907-system-backup-recovery-lab.test.js", + "sha256": "33c41eaa90333d40694bb837c83844a87c93d258773cd3fc1cb3df6f2dbc9297", + "bytes": 7168 + }, + { + "path": "test/server/ch-0908-save-recovery-plan.test.js", + "sha256": "13acb7fc1c53629c6fc8bf1709b3e766a34820fcae50f73b7d0d890fc76511c7", + "bytes": 6626 + }, + { + "path": "test/server/ch-0909-p9-gate.test.js", + "sha256": "da22fa835e7b03c3ee72eff90929bad94a0734a786bb76afbfff5b71fb2cb1b1", + "bytes": 4690 + }, + { + "path": "test/server/ch-0910-live-trophy-catalog.test.js", + "sha256": "3e2c11df30921854b11687370b9259a495b571520058d628ede4002fe53323b5", + "bytes": 12900 + }, + { + "path": "test/server/ch-0911-imported-trophy-definitions.test.js", + "sha256": "f9e1232f9d83ee226a41c1b20d3292b70cb8dd97567850102c7814b98b3dbf91", + "bytes": 2556 + }, + { + "path": "test/server/ch-0911-legacy-detached-trophy-preflight.test.js", + "sha256": "6db3c3f8b473b61c7cba55ba1ff1ce73db82e5c4959404a366db221dd32a16c9", + "bytes": 4770 + }, + { + "path": "test/server/ch-0912-inactive-legacy-trophy-unlock.test.js", + "sha256": "80ed14cfe95497acac551a0ba85dfb27910a67f45c88485c3c015dc28ad10961", + "bytes": 6035 + }, + { + "path": "test/server/ch-0913-legacy-detached-trophy-mutation-adapter.test.js", + "sha256": "cdc7009180b47a6c0126d7db1b5c592840e0cb5bc2ff35770dadeaa0e6f9dad2", + "bytes": 3565 + }, + { + "path": "test/server/ch-0913-psn-history-import.test.js", + "sha256": "4efb98d221616f1a432964058e92ca9c5e8d263ae07b8dab076b2fc5dbc32c00", + "bytes": 6183 + }, + { + "path": "test/server/ch-0914-trophy-history-database.test.js", + "sha256": "54a6ca254ef554e6486ed47cdde71a2748dfc5a2c2a6170a420039c2ad0baeb6", + "bytes": 2026 + }, + { + "path": "test/server/ch-0915-trophy-browser-auth.test.js", + "sha256": "f5de7829e73049768e45ba6666a00b128b74fce6140b32693d058ce1e6a54d8d", + "bytes": 3041 + }, + { + "path": "test/server/ch-1001-v3-performance.test.js", + "sha256": "321455ad966256a6094f03c713b7622ec99c6973a4ffb687c7cfcc2022fe0ab6", + "bytes": 3227 + }, + { + "path": "test/server/ch-1002-migration-rehearsal.test.js", + "sha256": "5eb0cae14302c206ee31c5a9b8f01f534e18dc12b874409a752f52c06a993052", + "bytes": 12475 + }, + { + "path": "test/server/ch-1101-ps4-package-backup.test.js", + "sha256": "58884ca795409cb5457a61a8af632292c5f5048c6d76b1dcb35591a24b97dc0f", + "bytes": 11813 + }, + { + "path": "test/server/ch-1102-ps5-native-packages.test.js", + "sha256": "654dbca1f2d8bd220905e737da7b9c57eecb55f5e845b8e7495f2daed946de97", + "bytes": 39574 + }, + { + "path": "test/server/ch-1103-trophy-progression-database.test.js", + "sha256": "a5e539d23436d3ebe1b6a3bdb260328546f97ae6186dafb37c5005e5d37982c7", + "bytes": 1591 + }, + { + "path": "test/server/ch-1103-trophy-progression-integration.test.js", + "sha256": "9a3a7d70867878dfc8f3dd764f3d3858908522e50ec516406adbec46c73298b7", + "bytes": 3486 + }, + { + "path": "test/server/ch-1103-trophy-progression-planner.test.js", + "sha256": "c53d858a80733568ee6075cc228b15c59105e642e5657dfee851316104a16254", + "bytes": 5480 + }, + { + "path": "test/server/ch-1104-ps5-console-staging.test.js", + "sha256": "199d4f6d807e09dd51a38db1f1b8e39a7eaf9b726461d639c6eb1ae2484e6e49", + "bytes": 13163 + }, + { + "path": "test/server/ch-1105-ps5-profile-management.test.js", + "sha256": "66c61a198a5e47fac229eaebda7c353fe8d3c4af5792e12c55a8533257276f2c", + "bytes": 11018 + }, + { + "path": "test/server/ch-1106-linux-listener-process.test.js", + "sha256": "5649257d5c9d2f950a8f52ebdf0820bfbecef9e4acf34136b3609ec8ec79a564", + "bytes": 15262 + }, + { + "path": "test/server/ch-1106-ps5upload-runtime.test.js", + "sha256": "08f662544ef6e514a9d7796247b03f456b3c54d90cfaf5755aa9dcc8107fe876", + "bytes": 32255 + }, + { + "path": "test/server/ch-1107-ps5-fpkg-installer.test.js", + "sha256": "75049c18e1df558e96f31240bdcd91e2df6382995680b36674a28c2f102198c4", + "bytes": 38007 + }, + { + "path": "test/server/ch-1108-ps5-package-comparison.test.js", + "sha256": "5b6713f02cceea35a48bb48f8b425b0298fcc8bf16aea8a118f35a1699c4634f", + "bytes": 5352 + }, + { + "path": "test/server/ch-1109-ps5-upstream-control-package.test.js", + "sha256": "192f36a65b6cf9d4915aa21508db46f69720ca5ed19b5e13203a30c26aa005b9", + "bytes": 6331 + }, + { + "path": "test/server/ch-1110-ps5-launch-reference-package.test.js", + "sha256": "c9e860e0d3ec46a8499e0ad21d1e8ba521828aa00963c133158a831126d7df2b", + "bytes": 8074 + }, + { + "path": "test/server/ch-1111-etahen-dpi-v2-installer.test.js", + "sha256": "5e7416d72cca88ad56ba7e48ef43f1957a1dccdc9d1e32f32f4d1b7e4b16e057", + "bytes": 5816 + }, + { + "path": "test/server/ch-1201-etahen-link-contract.test.js", + "sha256": "71f565322a8ea517109ba1de426a589e631e065f771b26ad6741092c4133fee0", + "bytes": 3739 + }, + { + "path": "test/server/ch-1202-etahen-link-agent.test.js", + "sha256": "d16d41b6426e96e2e386094b6acefe695bb2ae34bc810438354fee322663a199", + "bytes": 8202 + }, + { + "path": "test/server/ch-1203-etahen-link-inventory.test.js", + "sha256": "a75e328c1d87cff59c522c550328492f3b931c8e0e9a5b3867dd2f7c103e6760", + "bytes": 5735 + }, + { + "path": "test/server/ch-1204-etahen-link-ux.test.js", + "sha256": "eb8490cb5e2bc455b66734baed142fb119221ef0fee743c40927a8486da99df8", + "bytes": 3180 + }, + { + "path": "test/server/ch-1205-etahen-shim-installer.test.js", + "sha256": "c2301e079c593da66f7b241f3fd9b3341b44e27f0b77aa1febf7ef99984dc27f", + "bytes": 5382 + }, + { + "path": "test/server/ch-1206-etahen-link-orchestrator.test.js", + "sha256": "53b79e54999c2735cbc615432abb52dae15e2d2c196e54138e6f56aef4b8d435", + "bytes": 14219 + }, + { + "path": "test/server/ch-1207-etahen-link-operations.test.js", + "sha256": "3c0d7912b4144e7f15e7ef4f666a7e0294c682dbc86a56ab9dd91e32a61405ea", + "bytes": 66548 + }, + { + "path": "test/server/ch-1301-clean-ps5-fpkg-builder.test.js", + "sha256": "14adb8baf310a686cb830522fd358559aee4c69e734fce974b0c86b5b2fedd3e", + "bytes": 10610 + }, + { + "path": "test/server/ch-1302-ps5-fpkg-readiness.test.js", + "sha256": "dde0fdbd89a1b8e1c259d663f9ec9f5bcf7636e834636acef494259cf9492de4", + "bytes": 8872 + }, + { + "path": "test/server/ch-1303-ps5-fpkg-artifacts.test.js", + "sha256": "eb012a237ba3a77df3c192207bae4246386e15d94aaf26642cdd4204d9854db1", + "bytes": 9592 + }, + { + "path": "test/server/ch-1304-ps5-fpkg-launch.test.js", + "sha256": "d641f2ffc7f58a9df9acb8173d0cc2743370e1bcc187edf32f5ac5b6cdab7f4b", + "bytes": 27087 + }, + { + "path": "test/server/ch-1305-ps5-fpkg-ux.test.js", + "sha256": "6e5b5d3405710ee5552276563b988e89307215d24e8ec7d8f8220e200caf53c6", + "bytes": 6740 + }, + { + "path": "test/server/ch-1306-ps5-fpkg-hardware-gate.test.js", + "sha256": "95aa8ccd8f35aa3f6048c0a8fb20dcd227b9a0dcb0e2c9f7ae29206a9a91d6fe", + "bytes": 5773 + }, + { + "path": "test/server/ch-1401-autonomous-dependency-gate.test.js", + "sha256": "7e8e2e3a74bc714916fae6358c1f6e00bd1fa421c000be5c5a83f50d2dd7318d", + "bytes": 1547 + }, + { + "path": "test/server/ch-1401-p15-a10-product-lane.test.js", + "sha256": "4c76e273dd47dcd4b889e622ac4f69dae10cf12ee98dbf753e1772ee664cc38d", + "bytes": 20715 + }, + { + "path": "test/server/ch-1401-p15-a10-shellui-transition.test.js", + "sha256": "9a0e35e0b9d4b5265e658a0684e74d9a268e01438799dee3a6758af1032088b3", + "bytes": 11291 + }, + { + "path": "test/server/ch-1401-p15-a11-retained-launch-evidence.test.js", + "sha256": "b6b85b34a29ab9eaed8c5c646bd9b9f0de3719ea71f41dc9f8f36cf33e5d84d8", + "bytes": 13477 + }, + { + "path": "test/server/ch-1401-p15-a7-active-preflight.test.js", + "sha256": "6fb43bb26ccccebf3e989dd26efff8aa680ec72870aae20c541d11bc31cc9f77", + "bytes": 6005 + }, + { + "path": "test/server/ch-1401-p15-a7-route-selection-source.test.js", + "sha256": "dc10bfd6af98c16971010af55214e47bbea5ec043fd60ebe9e9dcc0c07a65ef1", + "bytes": 5427 + }, + { + "path": "test/server/ch-1401-p15-a7-route-selection-stage.test.js", + "sha256": "305bce2a0230942b940cbc876540bac04ff1059017d800e006cf726c7dbf2200", + "bytes": 9259 + }, + { + "path": "test/server/ch-1401-p15-a7-route-selection-trace.test.js", + "sha256": "da9179848e0ececa92c3a39e8c9319f9662bf51b656422a3c3c946a08c5cd7a3", + "bytes": 9159 + }, + { + "path": "test/server/ch-1401-p15-a8-completion-source.test.js", + "sha256": "1d0c8a5c878a0f2640e95148c5dfc5c418a84eb294d5b2448dd0a88849c33565", + "bytes": 7059 + }, + { + "path": "test/server/ch-1401-p15-a8-completion-stage.test.js", + "sha256": "6b9c6d84e0b96a6266ef1ebe9d4a89781d0aa6dc090ca031652c9ea94faf1608", + "bytes": 9503 + }, + { + "path": "test/server/ch-1401-p15-a8-completion-trace.test.js", + "sha256": "c2bb2cff02315d32ac828ee90a7ec647b9d4097b51e1690b8147106b787010f8", + "bytes": 7467 + }, + { + "path": "test/server/ch-1401-p15-a8-control-candidates.test.js", + "sha256": "ef8bb4920e30240957cbee8918b796358b1c393aec76163e421fd4408806efbf", + "bytes": 7565 + }, + { + "path": "test/server/ch-1401-p15-a8-control-metadata.test.js", + "sha256": "eac8e1a8d8ac7b73b5ea4deecb4f664f8b33f2461772fb549d372f1e3a0dcf66", + "bytes": 6077 + }, + { + "path": "test/server/ch-1401-p15-a8-control-qualification.test.js", + "sha256": "9549bb7125a925767b1b626fb0dbd574f04f978566da5e44b17343c660b6b999", + "bytes": 8373 + }, + { + "path": "test/server/ch-1401-p15-a8-distinct-control-install-gate.test.js", + "sha256": "23b054a771e7de9e88875b0a19170e0bee96ec0b0ec164caa883c0477d0ed7ed", + "bytes": 13542 + }, + { + "path": "test/server/ch-1401-p15-a8-distinct-control-package.test.js", + "sha256": "a29ffe66b97f4e99175bc51034bb9da59317ad908b0f79e24aa70b98a40426b8", + "bytes": 16199 + }, + { + "path": "test/server/ch-1401-p15-a8-experiment-ledger.test.js", + "sha256": "d73fad11b1844e2e48f59cebf96989089ad6d3b3c065f55633c54fc82038e4bb", + "bytes": 6260 + }, + { + "path": "test/server/ch-1401-p15-r1-control-taxonomy.test.js", + "sha256": "1910316f0bf70e9d2299d485f8abd7b17cbb974e1a8e32dc351f4325b273ec59", + "bytes": 4188 + }, + { + "path": "test/server/ch-1401-p15-r1-profile.test.js", + "sha256": "eb0bf7142dbfc5e8cf6098e86ae6d5df73e49687f2b63ba4ceb3c924dd18b0e3", + "bytes": 4220 + }, + { + "path": "test/server/ch-1401-p15-r2-installed-content-verifier.test.js", + "sha256": "386e92d68490e526d8a9bfe48aeb4babaf303ef4282648ac3f58cf5d097dd873", + "bytes": 4473 + }, + { + "path": "test/server/ch-1401-p15-r2-product-lane.test.js", + "sha256": "458fd6dfa6124cdb2ab61ae3db9402dcb78d3c7c852e9614186495beecc1f200", + "bytes": 4775 + }, + { + "path": "test/server/ch-1401-p15-r2-qualification-service.test.js", + "sha256": "6626c99aa1e843702e68d42c5743cd1b1f6360061f0575649036259ef476e6b9", + "bytes": 7639 + }, + { + "path": "test/server/ch-1401-p15-r2-qualification.test.js", + "sha256": "503a51d1382734d76b0f443b013bcde7fa15d5ca78e63aaf50755996f075daef", + "bytes": 9097 + }, + { + "path": "test/server/ch-1401-p15-r3-host-artifact.test.js", + "sha256": "90d581d37a57b74947400df86ffdad4f50fc8d15042eafa6ce5752eab4cb958e", + "bytes": 4386 + }, + { + "path": "test/server/ch-1401-ps5-fpkg-content-auth-trace.test.js", + "sha256": "2c9293bf0e13517549d5ccabb603bc1621e79820a2d096775f1811558cc76809", + "bytes": 9551 + }, + { + "path": "test/server/ch-1401-ps5-fpkg-kstuff-runtime-identity.test.js", + "sha256": "de36e47a1429e1977907c44987da31a1e20667029f509e7cb60e2c03db64d45b", + "bytes": 7944 + }, + { + "path": "test/server/ch-1401-ps5-fpkg-runtime-qualification.test.js", + "sha256": "52da176e26b42b1818d9bac4c3b82de121c04044d6949689055f492d1cd6c923", + "bytes": 37642 + }, + { + "path": "test/server/ch-1401-ps5-fpkg-safe-runtime-stage.test.js", + "sha256": "d153566f4af39e5810688c23a3ff782e3d11377ad9efb1086213a61170d89215", + "bytes": 7786 + }, + { + "path": "test/server/ch-1401-ps5-fpkg-semantic-reader-source.test.js", + "sha256": "33884829b7a769ef5096faadfc6726781e6a5ec37f5b611329ae73b1978947a7", + "bytes": 1938 + }, + { + "path": "test/server/ch-1401-ps5-fpkg-semantic-reader-stage.test.js", + "sha256": "ba362805963ecaf928b91b3d938b46a8c4585e4ba7bdad53dc2edb7af492a595", + "bytes": 6577 + }, + { + "path": "test/server/ch-1401-ps5upload-runtime-identity.test.js", + "sha256": "dc6af0282e59bf76edcc475a15f3efb2fa9ce47d57213b16d90f96d2f021f21b", + "bytes": 4712 + }, + { + "path": "test/server/chimera-beacon-client.test.js", + "sha256": "bfdc96e9c0f81e42ec1c5673b6dd2b2d51c5626eeed33f5cb132ce9d0f644e34", + "bytes": 5212 + }, + { + "path": "test/server/command-registry.test.js", + "sha256": "60c9be7ca3dbdf1e566cc808fc06d0f23ebc12ab64adc36f5541edd789bcaf4a", + "bytes": 4117 + }, + { + "path": "test/server/companion-worker-service.test.js", + "sha256": "c501c73fbf5d0e8f0b78523e2da36e7dc30805897d7299aca8978365ff68dd16", + "bytes": 2963 + }, + { + "path": "test/server/console-profile-service.test.js", + "sha256": "f81874c16e6774f983802b49302deb11e678bbc2db45398b2a2d9b2eb1674c22", + "bytes": 4506 + }, + { + "path": "test/server/console-scan-reconciler.test.js", + "sha256": "823b7aeb3387e824c06eb5df2a4daff5e6a304230d203fb7471921c2daa6344b", + "bytes": 12873 + }, + { + "path": "test/server/conversion-service.test.js", + "sha256": "1f6713eae20018903bca635bd339d2a4ad8eaab2293891dd8b517b8c44293013", + "bytes": 2553 + }, + { + "path": "test/server/database-migrations.test.js", + "sha256": "ff8dba2eb3b214cb5a5ce9894ca103ea91133aac3085ea04fa2f35367fbccde7", + "bytes": 21032 + }, + { + "path": "test/server/dependency-security-regressions.test.js", + "sha256": "5bb2ae2120007df4e1afa21adad2e92d8f075fb35efa72b929f228935f07a139", + "bytes": 1704 + }, + { + "path": "test/server/docker-runtime.test.js", + "sha256": "368d640013d1a6903e79ff9eedf8558d8b5386aa74d6c012607721bc25d03baf", + "bytes": 3980 + }, + { + "path": "test/server/ecosystem-intelligence-service.test.js", + "sha256": "95a37bab8b4460296823b4adca1b3a97fd57d824def695405b1750407f12b8ba", + "bytes": 18630 + }, + { + "path": "test/server/ecosystem-service.test.js", + "sha256": "4062b498c8fc754f4fcad7cf39825598f70da16c4d5b6b4d46597c15f5e9e253", + "bytes": 16839 + }, + { + "path": "test/server/elf-arsenal-client.test.js", + "sha256": "7a0b3beaef4d5945dc8855442041bc8932c34267ddb27ec1a38ff53fadce45fd", + "bytes": 1631 + }, + { + "path": "test/server/ezremote-server-client.test.js", + "sha256": "f69f1d1ed2fae1df998d6f37b6285c76f18c96829e5f6991c9ae350d39d1232e", + "bytes": 4089 + }, + { + "path": "test/server/ftp-list-dir.test.js", + "sha256": "e079172f2ba20109c64aae7f9daa75713f318f69e966262761904d6987dd8795", + "bytes": 1219 + }, + { + "path": "test/server/ftp-scan-root-coverage.test.js", + "sha256": "cfe01df20e4a6dfcd444b8281bd97d76bd5521b37afe2ea03c30f7e5882e5679", + "bytes": 3748 + }, + { + "path": "test/server/functional-modules.test.js", + "sha256": "4156414c76be6e9e16a0bb39f2c8c98f961bd767a2a3e0ac9146cad137145098", + "bytes": 13083 + }, + { + "path": "test/server/game-metadata-view.test.js", + "sha256": "6f25e4e15d9ff1b7a1aa44c963c0ad082d24173d00ad517ac29b86d104ef1ae2", + "bytes": 2789 + }, + { + "path": "test/server/game-metadata.test.js", + "sha256": "ec76ead1b998f10947d7640e97f8d56b8b10ea587d8cb627134738e413b9514c", + "bytes": 12087 + }, + { + "path": "test/server/game-update-service.test.js", + "sha256": "0d0c0494d15f14af104c60e6dc74dab835638343c1918000a420f6a18433c9de", + "bytes": 7539 + }, + { + "path": "test/server/github-security.test.js", + "sha256": "22a3c84189365489836fbcb0bb26fe55b01ea8afe6d4748b1c41c8c07477a51f", + "bytes": 1275 + }, + { + "path": "test/server/headless-database.test.js", + "sha256": "3b39eb55e57c9e3624f9e4e494ff2699446f0830616cc85c0d9c95165eabc943", + "bytes": 6769 + }, + { + "path": "test/server/local-library-scanner.test.js", + "sha256": "3ecf919059265fe35ef8568875fb457a86515a5625e79b6b69883b36fde759d6", + "bytes": 12467 + }, + { + "path": "test/server/notification-service.test.js", + "sha256": "7b3520dcb08ab699bc961b93f85cbd78c297aa8a4caaf46601a08dc1056352e2", + "bytes": 3295 + }, + { + "path": "test/server/operations-service.test.js", + "sha256": "a7af2235eced5f7a77ae2eed1eac1bb9f8f1114ded0c83cf85c74f16785827de", + "bytes": 14935 + }, + { + "path": "test/server/payload-manager-autoload-repair-service.test.js", + "sha256": "561a6ffb6652879b1ac86a57cbfd650f8dbd5fae8c8ec2b53cb8957278d58b9a", + "bytes": 16854 + }, + { + "path": "test/server/payload-manager-proxy.test.js", + "sha256": "7fd88e55127c36bc4bdec50cf5582b3187b55174018851df4e43eec2cc0fb294", + "bytes": 1680 + }, + { + "path": "test/server/payload-runtime-service.test.js", + "sha256": "b0019458edfa2c66a9ff68f8779c2da7f89ed50692d61d01ae2c6fc50f6b3c83", + "bytes": 4436 + }, + { + "path": "test/server/ps5-klog-service.test.js", + "sha256": "3413c59163da531ec8797ef7422307e7f6c37b552212bfac31dbccfe27703ca0", + "bytes": 5582 + }, + { + "path": "test/server/ps5-payload-manager-client.test.js", + "sha256": "3b92f01a779c41939ceb5408dad8d183f68cfa1a843c4125ff2ef93f6b75ad30", + "bytes": 5342 + }, + { + "path": "test/server/ps5debug-readonly-client.test.js", + "sha256": "d7952db43449ef2c1f21ac4d4fac6201ed82479286545ba2c0db5fa5123d94d8", + "bytes": 3268 + }, + { + "path": "test/server/public-readiness-security.test.js", + "sha256": "b39e4e184aa8b3cf905332b44e2f380c19e26b8dc4a2d641eb91a81433e477b8", + "bytes": 5322 + }, + { + "path": "test/server/pwa-security.test.js", + "sha256": "7cd5970b439c391c3956f6b5e08dc38e29acc6a11a3b0e6b7818e9378f09f729", + "bytes": 979 + }, + { + "path": "test/server/read-only-sql.test.js", + "sha256": "b7e044d3263d43e2e70bf692103020da27b1839279c630885c6e7acd7c7e10ab", + "bytes": 1380 + }, + { + "path": "test/server/remote-play-service.test.js", + "sha256": "318f6b6c8da4cdca949dd441d330f77f342b65d2363595ca38764c3278259095", + "bytes": 14009 + }, + { + "path": "test/server/remote-play-worker.test.js", + "sha256": "a38922fca409c6377dc0c5171af0ccfd5550d697c02fba5a7538977f03cb80fc", + "bytes": 3077 + }, + { + "path": "test/server/renderer-security.test.js", + "sha256": "4e407f8cf87a8c577418b7c59e661695f1615d51f95b5644028bb379ac70653e", + "bytes": 9031 + }, + { + "path": "test/server/security.test.js", + "sha256": "0b8765fd3bfb71c51e15750f17d3e1f43c4ad4524a7f7a995e93f9ba5c3b73d8", + "bytes": 2290 + }, + { + "path": "test/server/server-login-client.test.js", + "sha256": "3c382c10eaade160d42132fa618b11bff08f99abadb6d05179812bb064593ad4", + "bytes": 658 + }, + { + "path": "test/server/source-classification-service.test.js", + "sha256": "4f85d211b7604a8cd85d3571b9852f8c56942052539bfd9a63d4b934c1942ae2", + "bytes": 1904 + }, + { + "path": "test/server/source-scan-route.test.js", + "sha256": "da3b9d0ab93964e30436ed5725ec99a345264085e806e669451b633e944fc525", + "bytes": 941 + }, + { + "path": "test/server/title-workspace-a11y.test.js", + "sha256": "4008f35f099663892385c271ce2ff9530b5e9e5c430fa41a81d9ce65b139527f", + "bytes": 754 + }, + { + "path": "test/server/totp.test.js", + "sha256": "f6df565600b658a69b7544cfe5001dd3767c089bd8a3fe9f8c2ec10eff6ae8a4", + "bytes": 1576 + }, + { + "path": "test/server/transfer-service.test.js", + "sha256": "d05dd2bb7e7f0502597a390713058c07f7b0db94757105ebd494d67ee992941b", + "bytes": 5338 + }, + { + "path": "test/server/v3-derived-views.test.js", + "sha256": "d22c8ab9c5f923fb9e37bccf83811a7d82e242e7d75657005174b30febc1026d", + "bytes": 12147 + }, + { + "path": "test/server/v3-repositories.test.js", + "sha256": "c30d2c1d192edf3611fdc4eb6d3b5171db2294d6490c25455c251a9e7094d8ba", + "bytes": 8829 + }, + { + "path": "test/server/web-command-contract.test.js", + "sha256": "d4e5a9460c7f17bf0c31dacfd9130c9aa5b5a2b727b9428d9bb3914a985bf402", + "bytes": 954 + }, + { + "path": "test/server/websocket-backpressure.test.js", + "sha256": "6106fc957a4f705445a9903e514e3224a895713008e1a8c4b95208c1097d8907", + "bytes": 890 + }, + { + "path": "test/system-state-client.test.js", + "sha256": "bcfddb2208eb6bc4309fa500784646300f33e2d123b94108ecdafc7a3d4f27f5", + "bytes": 761 + }, + { + "path": "test/y2jb-generator.test.js", + "sha256": "fe4965e762f3f14543b8dec4432a3d180c5f733f3c83745cbc907409b719e7df", + "bytes": 2274 + } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..9db512b --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +# Chimera — Public Source + +**A self-hosted PS4/PS5 library and console operations platform.** + +Chimera brings library inventory, verified backups, durable transfers, media, console services and guarded operations into one browser or Electron interface. It is intended for consoles, games, backups and homebrew you are authorized to manage. + +> This is the curated public source edition. Updates are generated from the private canonical repository after validation. Private history, deployment configuration, live data and third-party binaries with unverified redistribution rights are excluded. + +## Interface + +![Chimera disconnected dashboard](docs/assets/chimera-overview.jpg) + +*Reference capture of an unconfigured, disconnected dashboard. No live console address, account or library is shown; the interface reports unavailable capabilities rather than claiming a successful connection.* + +## What it does + +| Area | Purpose | +|---|---| +| Library | Inventory titles, metadata and locations across local roots and a configured console. | +| Protection | Catalogue backups, verify hashes and preserve recovery evidence. | +| Transfers | Track upload/download/install queues, retries, progress and completion states. | +| Console | Observe explicitly configured services and require confirmation for sensitive operations. | +| Media | Browse and export screenshots and clips. | +| Payloads | Review source metadata, checksums, compatibility and manually initiated workflows. | +| Remote Play | Coordinate a restricted local companion; the native client is installed separately. | + +The server uses authentication, CSRF protection, an encrypted secret store and exact destructive confirmations. A queued transfer, an accepted upload and a verified installation are distinct states. Missing prerequisites do not make a feature operational. + +## Try the source locally + +Requires **Node.js 24** and **npm 11**. The browser server is the simplest source-only entry point: + +```bash +git clone https://gitea.itworx.tech/Jens/chimera-Public.git +cd chimera-Public +npm ci --ignore-scripts +HOST=127.0.0.1 PORT=6967 npm run start:server +``` + +Open `http://127.0.0.1:6967`, create the administrator when prompted and use a unique password. Server state is created outside tracked source in the application's configured data directory. For a disposable evaluation set `CHIMERA_DATA_DIR` to an empty directory. Native optional tools and Electron require their separate prerequisites; `--ignore-scripts` intentionally does not install their binaries. + +### Source-only container + +Create the external secret files and configure the HTTPS origin before starting the container: + +```bash +cp .env.example .env +mkdir -p /secure/chimera +openssl rand -base64 32 > /secure/chimera/master.key +openssl rand -base64 32 > /secure/chimera/bootstrap.token +sudo chown 1000:1000 /secure/chimera/master.key /secure/chimera/bootstrap.token +sudo chmod 600 /secure/chimera/master.key /secure/chimera/bootstrap.token +# Edit .env with your actual HTTPS origin and external secret paths. +docker compose up --build -d +``` + +Local Compose file secrets keep host permissions. The image runs as UID 1000, so ensure it can read both mounted secret files; adapt the ownership/ACL for rootless Docker. + +The public Compose example exposes only `127.0.0.1:6967`, persists application state in a named volume and runs as an unprivileged user. The container binds internally to all interfaces so that Docker port forwarding works; HTTPS origin, host and secret settings remain required. It does not mount your host libraries or bundle console payloads/native conversion tools. Start with an empty instance before configuring read-only library mounts. + +For network access, use an HTTPS reverse proxy and configure `CHIMERA_PUBLIC_ORIGIN`, `CHIMERA_ALLOWED_HOSTS`, `CHIMERA_COOKIE_SECURE=1`, `CHIMERA_TRUST_PROXY=1`, a high-entropy master key file and a separate bootstrap-token file. Keep those files outside the repository. The server rejects an incomplete security boundary on non-loopback binds. Do not expose the plain HTTP evaluation port directly to the internet. + +## Usage + +1. Create an administrator and review settings. +2. Add an authorized console host and the services you actually run. +3. Add library or backup roots with the least necessary filesystem access. +4. Connect manually, scan inventory and review the observed status. +5. Preview a change and provide the requested confirmation before mutation. + +Chimera does not provide a jailbreak. Windows-only helpers, console payloads, firmware, package assets and native conversion toolchains are not distributed here. Their associated workflows require separately obtained compatible prerequisites and may be unavailable in this source-only container. Desktop packaging is not a supported public artifact yet. + +## Architecture and validation + +```mermaid +flowchart LR + Browser --> Server[Authenticated HTTP and WebSocket server] + Electron --> Services[Shared application services] + Server --> Services + Services --> State[Local database and encrypted secrets] + Services --> Console[Explicitly configured console services] +``` + +`renderer/` contains the interface; `src/server/` owns the server boundary; `src/services/` contains domain workflows; `src/persistence/` and `src/security/` own storage and credentials. Synthetic tests are under `test/`. + +```bash +node scripts/check-syntax.js +node --test --test-concurrency=1 test/server/app-server.test.js +``` + +The export manifest records source revision and file hashes. It is source provenance, not evidence that a live console operation or deployment succeeded. + +## License and attribution + +Original Chimera source is licensed under [Apache License 2.0](LICENSE). See [NOTICE](NOTICE) and [third-party notices](THIRD_PARTY_NOTICES.md). Porkfolio by StonedModder is the historical starting point; the owner confirmed that its original code has been fully replaced. Dependencies retain their own licenses. No rights to Sony software, firmware, games, trademarks or third-party binaries are granted. + +## Security and changes + +Use the private reporting instructions in [SECURITY.md](SECURITY.md). The public edition receives updates from the canonical repository; publication does not copy private Git history or operational evidence. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..db937ad --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# Security Policy + +## Supported code + +Security fixes are made against the current `main` branch. Historical experiment, diagnostic and development branches are not supported release channels unless a document explicitly says otherwise. + +## Reporting security issues + +Report suspected vulnerabilities privately to the repository owner. Do not publish credentials, console identifiers, account tokens, private network details, personal data, proprietary dumps, signing material or exploit-sensitive evidence in a public issue. + +A useful report includes the affected component and commit, minimal reproduction steps, expected and observed behaviour, impact, and whether the issue crosses an authentication, CSRF, filesystem, process, package, console, deployment or secret-handling boundary. + +## Security boundaries + +Chimera deliberately separates observation, staging, installation, launch, trophy/save operations and deployment evidence. Contributions must preserve fail-closed capability checks, exact confirmations, provenance, backup/rollback requirements, path containment, secret redaction and the rule that acknowledgement is never equivalent to verified installation or launch. + +Never commit live `.env` files, tokens, account sessions, private keys, console credentials, deployment credentials or private operational evidence. Use documented environment variables or the configured secret mechanism. + +## Disclosure + +Coordinate remediation and disclosure with the repository owner before publishing technical details that would materially increase exploitation risk. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..12da6c0 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,7 @@ +# Third-party notices + +The Apache-2.0 license applies to original Chimera source. Dependencies retain their own license and copyright notices; versions are pinned in `package-lock.json`. + +Historical bundled tools and payloads whose provenance or redistribution rights were not established are excluded from this public source and public releases, including ELF/BIN console payloads, native EXE/DLL/PDB helpers, JAR archives, firmware and package assets. No license grant is made for those components. Obtain optional prerequisites separately from their authorized publishers and comply with their terms. + +Porkfolio by StonedModder is acknowledged as the historical starting point. The owner confirmed that the original code has been fully replaced; see `UPSTREAM.md` and `NOTICE`. diff --git a/UPSTREAM.md b/UPSTREAM.md new file mode 100644 index 0000000..048e715 --- /dev/null +++ b/UPSTREAM.md @@ -0,0 +1,7 @@ +# Historical provenance + +Chimera started from Porkfolio 1.0.9 by StonedModder, at upstream commit `5790cdad0bfa38234ba20f81c5d4845827609549` in https://github.com/StonedModder/Porkfolio. + +The repository owner confirmed on 2026-10-01 that the original code has been fully replaced. Original Chimera source is licensed under Apache License 2.0. The historical import record is retained for attribution; it is not a claim of an upstream license grant. + +Console features require separately installed compatible services or payloads. Chimera does not provide a jailbreak. Native Windows-only helpers, firmware, package assets and third-party binaries are excluded from this public source snapshot. diff --git a/build/icon.png b/build/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..8788706afd72c64175cb4a82f4e83af11226d30f GIT binary patch literal 353492 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

KRMA)m(>R_%2E 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°6WemCcGnMPifZzH~_9A6$goT9JPM&-+c-lO;ated7s9i zaiw!>6hW`H&y_DjHc;RuW$$%6LC-S z%J8&V8OsL5cn1XEP!?JR(tSR{MEi2DJJ}qV7aF2T$4wfMEY5=cs@}U&g@&j{dmShe zl7z67|BtB;Iwy7gmbaLS@r`D<0wghszYlLkI}bPmzQSVm-ckD`c&SO>lEwO+s$EAe zX6-`wr~t;Sb!}OQR=Yss^(R{38RZZZN~o{3h!C(YR46Cz!1Dw^2^_-H13B=3*lvj} z_^w6pF#Kz&qse7j@0X&k#!EKJZWx=-Zjp9}!oH88x! zq%uVx3u#>T6#{k%mhM?;LbMS@?+#iD_h~3+Y%Y~>H}9-?W^H65(CDcWZq)Y?s?*FL zm68%k!MLK4T*J}XL|xw#KayJ+um)_toOLr3!;!j&ybuC!I8vHo(!C#CyDj zk-_jR_UKXpDUn4q>NNK`OybnHZa(CA9L3EF?$n&sSmEFL$pHC}OI2Z1g;_KA%jra8 zOT@3Q?Av^q0qcrccY}(KUHcWHisuHkVWfq;(^P2)xFXN@I5k{%heing-yCdnb>=-0 zLOM}6&W%^glKGb-{4?xt?OXrS%)k80{0-%&`v;slrdx{&mkov+`JkB{57al^kms*b zjPoj)-Ci;;zj5JnSW_Mw&gCoaq|d_{I&Ey_U(c}3VXU<{K2$Viw}jV!GyE;mSC7n> zQ<{vJ7}yQW{dPyil)LmqCVRCO=>HATN(KR)aax^oZzZ~W z>#@*}qQt#h!)hg~IFiuzL!!4SSf*Fuhcr6sM055qjN(H}!m@AVWdzt~HG^)y&;9L6 z8veecM9+!a9C|!%#`*OfSc7tcz+PRdi!5H>*9;)eD3{jORbN!=5U?Ztga2d0jb=0B z9T6jE&KR`z=kvD*+qjYLtE}S3IXVIoPeCvpOv$)f@+bDn*Gz2kKYj^mi^5lZ>aK>0 z#K_Wj=6vchmj=jB7Lt7G^deYRU_?IA@y+teK>bT%~!Z{>f;B%_QimT{k^5d&(Xfjpd4_! z)<2a|x(;oe6a}K6RZY;oJvTi840XtleiiR~bBHyXc04yCq5neBQU7v%&eB&zjGA*x zW8#jKQwF*s!ZcHa{k@Lk>g@;jvJ$1d825s+u(5pslS85R8*WOo=8klEno*- zFu;hN!(|$F<0a+EW|?*nOqZKyJ_%4HkZ{cyQLc!}hVQd(Bc0Lc1$)kRZ6m^tk}5%a zlJEna=dsl;v$R=eC>J3@a$3(h4nl5S_FSQume{bv{lQFGhL9OTLWW;*r6OI&MpZ#* zy7^Orpd0yjRz5xmAbBupDptb-4tK|ot<5AHr3up@7FAdOO`^LoLfkcSpr>`*O`pG+ zF=Ek2AOFY0q2VuyGb&;lm17SZQ7*(^0bN$gLTXagEJk8B<+fQ`Ylp z!A<}X{BpT;%~XUNFk6d5#Ri5?eecnzNGJ6*?PG9Pr~Eq{^NlE{8MBjr=jwlc?7HP1 zX%O^r6Ai8Rk$g*0I;6&^4A)?1^P8BsT~fA#s_ogs+K0wfIng2wy+fCh0h0V0rHjv) z=>6~H4qDkx<$QnZ#<2$fHuM$s4?z-Me6GFwfe+!={cI7M*Y@99X&V(9Z6^cs*5R0R zzZJ8CNM{j#>F|34LvsQ0_=gN*7+?Q6aajX|S$$cF`xnQ$xunIa(S7LT&G&!1Gn4!C zUrkfq*<(i6ITl-gz53{PCE42r3<@|_^jWQ3|2Tk>I`+V%#bInM&iMEC1~Q6*>7m>Y zJUr^c4k{bWA@Z$`Q5arRH^bC0&UO_Z2@u1ubmiinYRyX(<-O-&pMZBxQfKEZ8!_^O zf=!rcnJ4vG@0LkQToph>1}b z39S0<%Vf1m?x3;*3W;eIZy8AFgo#mrvH7I{U(5?3>Egi(tl&)?JJzqMhjzf7ZRD@K zA?985_MHx;V+S(CJc8yKv<%TMj4fx6XfY^Q(r%YFW zc0W^yx5A*@iRGgEFJ`a~bd%J_NJ3{4MKy!9ghD$s=X+#9(q(?HC>49=WL)&3DE(vo zsqX)Zq3@88JxL4>brWp#?h`AP?q7#Dg_376PcBVV{0ChR%wF-~)NdrSztKu<#!&rq z@viQ;dE745pHTIfwc7ctfKMPe))zB3OkgO#!7O&nvA^oQI#%)HD6d?Khn}r&Pn4?K z!}0CS&`uGkCX~tquTYC>qlCoYhg}bQ0+@g!;W`%DAj7aH*^_~6ZBF#_)+cHXhcbo! zlj`9j1Q9Kl=A#I=N`nWFHmxpR9pQu)Kly;*%Sw?#a@(OW!utRl0K2zv`wrPe`sb>j zWq9n~EV0qSSlT_-u$Q^}li6$@R%PSO6R?&=F|T6*hCRN&`fNCGKf6?IBd;_REmu=T zG83sFM`>kJEbUdBVt(xSzR$AFelBbEzd<+?;Alt6!K?exc=wURD`|l0HW0!@KD-o! z1O6#Bv=jmvR}LJJIA*s9wTtvzF~T-8`+W(=Y2JelHI76&Iznm-}~do)657s_&Nwg3(QXA7f53Sd)X3Rq=ny3~rb!cH7}XnpjPl-R zjPiUACVn>##(9|o8+y)9Sy|m3d!6v$7`;{+TiYYTu}QcQOs#d0X`FZ2oOfP0_?P2@ zxq2wA&6mSRL<=2qP)H9;PA%U9&xbe9b1is(u^KpN-D1hQy|_=SPD$EpJ0ZI=+^#sL z16;{GP9)g?qvY)mEt!h-%@%!G+5^s{6c{bD)%R{&8OHky)EXy|D=*)a{8~30;3Tg^ zwp>{gD3@Ajf8jbVx(BRTkgxoQqCfUT;VpuZw4r}tV|#n7ZMDbZQzEs;;q*|>3ghek zsHCdJS$eq?qH_PjT?C;Q(=#PTSu$fQG0*~T)vx@Kie4IP0=rSOn6;7YcT!NETSC8| zUkBe=0(0IGjJ<5~G5fV>VRjX$M--7rH1t(e;SN89VIRgKkBdfs#_|Bl1sMwfH30Me z%0S5i@EdeKoMwbsQ*;Fd!k2+1JpZQZ?kgARx^-1{TRrwhq_~v%is?~cCHwwciKk>W zbHR{~bZf&be~N$Q-{$Cpb-|o^%F+5zCx?JUCeSA&@P!aWACsjQEQ3fKRv9N^VrlI}0fR-NSLSw07e|eGcsH^H6V=_IowR zRGd;QP>_V3MmXM3E8@{cEKmnzQ%zTd5=Pe2Nd9E2@Whi;;BCq9NcBWst9>evT%y=&5WUi zYoX@tbPnHnev5adnm6gJ4nOws>w4{y0BoRgX{^BLZ%_m-&ke6Ep=^e!)}S|uD2~>% zR|ni&c4A&v>j}FYx6e?i7sYA-Y5Eq8JKMo??hb$*Sp^p>}D< zY>@Twiz%t{VIziZ|H+{5YR;kC)<-_capL3T%~X}{A)QipJA9-xbDBJ>qQ*I<@qtnC zy|3=890`rdTc>r`b$U{K=Ti?-gYR45UAAvel&CaE1DKM5iDozUqmbn8oxo9zu~F2d zw=Rz0GeT_|ZVbcub(8FeW|N1WhVr)`0= zB1^J~F&J8`Ojmg>p-^=NhL$bVS%Jad=z%Lng4VUC&cYMl9X|`BZ2M^f-fVgnlYR_n z#}j>}fsLUft#Oq}Ev6NHMW@nw5UuV$;&aRL`Z0$3`7VmwHC|z)bE5=8zqO$_j$s_X zn}MP*@IlL~Oy0;-ny2#LEFEzpZ_U7g1JRVty{YUiZnN1aK(YUcy2-S>yWdKj1}I{& z%-v@t{-M;`u)tqj@EQyB3V-oe{$y2ud~qf}-#Wj5<3T+|g9)%Q9kv48RAS0mYIqJQ zAdtG&|3u!0YIV~&Rmo!cj9$n+h*8pnYM-kx$i96iqY?kE%ji@s5AllTuSiT(K=Rf4 zdqTXdOSf#GUB;j(-ofh_gD=kq3!pjvr~|Be|9i4jI32Rce`BUFMTsX0qY|5ln6XMW z{4J*T&-`N{fD-XEmS2|*53ia{Nyb`@BeE9o*HDQl*rIsDlg~jQvW)NIM7?d_3M?cG zG$8wmXQ(r{L#iT^up8WD<&pe^jS?Ww-CDDJ%XJtQSW~mXJWd;t?#s>U`1(5nf)$U4 z5U;D7qh%rH7f;^(wvUxpm)LRY_WILX*Co=(d&dLU^-z`QQGMOI!@*4{a+Mm#|(S=d}s8%qdG6X9`uwv=` zw5z>Pio&-nbh6wC_LB-FJF?3rI#fza1Fkzw|L?=NmIFq@yrZIq3LtFLg?WJlTMPv8 z$5#JPe*)meja4B-t3ljJ=#}|;nN>D9+)4IStwj^G@xgTk%3AzR_%R?~^ia|)F7IV~ zuO%sk*sIaDOtsm=^X$=~$>8C!(Xiv4BSyFwai-|*V~cnNJd+=e5Nkf)8OOpuCy~Az zwzFT?NwzZy92xJq^lHl8aFvE@|NHn%E-m%(0NaOxP88N%S2N^^+|;d#rM99 zR5tYt!u*~=x+tCwaPb0Y?hksv=PcR(<#dy>P{j1;={5+rhG^)U@L(v&rx(fEnT}+= zPz0!Gil`JcfE|U^g}58>d84Td?6Ky1Acmwu)vciVlg_%SQ;t)Mz?sI@6yjQSRHsY$ z9qM&^+ltIblD^4mxomKjAY$aeIX^7+_VI)I93?6` ztXJB9tpDeB?1jRU{Sz@n`-JA-Dd*JVFxJl(Yh$S^frV(8-wqb-Wn(*o>Z#=A(ZAqS zoSSoMTqovRqwegKALl?XBlX28cz*gU{oJ10u*y)cvSa@bW8SJi`Z~uEv|S1)xh6N4NB6c6@GuNd0_Kr!R(0V3v{-3fo&u|Wv*_2b_+)J zI7*XYBjfR1qA%Jxc}>+|P8Kd4Mq-QST_uir=DH(Qwf9)s*u$(+*a%~Tt79Y2K%~P| z>qmNiJxC&@q^q6i;C`(OZ@_^6neER#zh<;*ns!#YW^(_~H=*Nytqyh%A&BE9XB)V) zlK}Te>LuhpwLFxy_s8axe6VaA2x%fAq78K*v&E08q1RZ%EtJb~@H2>;lPBd_e@%wY zMN54G|6ZZ}xYHW-A&8nvH;|)ki%~jMpVKX|7D11L(sHbKZt(Q6*?OPKfV$&H+!R zSGIqH7uyaxA@jIVA=Hn4=ff>QQ(OO99!zVlBx3KG8#uP7ulOqx z|95wuR{1s2RLn_&@2st;(aQn)_#1=+Po<-uE_<8bfPpgW@nT47SkJe>8?sl9`uVunp-8*hC+I28Na@oID&2e*Du}UsS7Vh4iKV z3k3{L7(JSt=*^0WdXlDC|9M+_Me8T_s)t8wB5{iuu_nZOmi+9A^3)>+_XBw|bzk@% z3>LSKj7po55`vn|dh2-rxsNiM437)Lo_Q69hzStr+g6wk1lTTX{|d+JY&zN;#Sb-5 zH*EO({m3xFe6?SfzMnGPvoz|m1RY0LN~w7y<^A~wO)E}M*plVgnk2e&|NVJh4tKQNQ8ju ziwFk!6e$5KfJ~sGC921M>j6eifMG;s(d?oBlB=Fq*yH|$IjXKl_btn! zj9Mb^sfWrJ__=QO)x{t*YSxily8Ivpe=CmGZLq`_$If(0w_sO87D|P0#>A=ca-B(y8!cAodl6L2X z)`|DUO732l-}L3td~Y7?M;sS`@+!AfkxEt|Y(4HmE&3KtF{SjEFddslK| zKpJ~}QCaumv)5GJ~b7Dd=99UatQi$1hKk@TK&*> zcK8(xZVo2J5twmOy2fkIrm4hs{e~}t$X#}4I0nwVBuFD36_Lp}W`C+cAp}0WA=N8- zqt(1blH)-IPWP>*aiG#$I}oCaELs4)z=iCrC?23c#2q=-Y$(=QXBmg$^yY{R9=^&X z0?eH%zQdT0A?sC)pW?I=MZoco6Iz@|12_tEd@Bq;F+UXp3E2(}+FN%MgE(>FYhrK+ zyHex;4T!c|f2N={zAcy_mYI=2{Oe7<956V=qBMf|mq)`Yi2Z{hSX~fm>6G*@&NtLX z&JBX2{ZjEiWznTfxdRz4{SeZ3SbQ@g0eY$|k&cA)C?+lB`Z;}K;aq;OPaYjjNJu~@Lo zXQ^StqfF^nlBYE)LM`Prs6_!*@1jui?UD!B!EJMNu-T2KRmm$u^RL=y@4VAVXU-1w zRq~Dr`(??9mP8!3sMae(q!Hue-|-Pr@Bu%s?E17)mQ)3q|tH^I=%g6 z;s7lNlS?>u@tV%mlEJKl_AO6uA5PF)Iv@8@Rv!mG1|({d5TJw5eToTIXLay`(t`tV(6gtm_==;1m_BeNm{AdzG%5eehbs?ji?h?quo>+|X7~i3OIaA* zko;am(waX(KWbu%7|A|76>Wl3PfZ~p|8bl;>*U5@?f{Ee-P%m#I*eEzXvg8b-gLgGm*V&%5>+G!5{hXy_x=Ft}(gJhqKfVk)50d!R88 zE7gxOqEQz`3XJdfN2SGJQgx)poasBy%lU{lZ~+I1f3t^vg~|?5{iLJd!QfKSk6 z6MD^?m(f62{+%4HFkWS##0#wk-56}97l?tR2wst#fu#-aVGN*y0_eFStr!SQ3C!#Q zRZZpTky3nVb)f=i0CsHB+$9-6I?F&A%E45S_9JYtg*H%5w#ced@|Jx;!z;RSp`%d} z{0BGp((o}+;s;tIy$aV92h#(7@R>ropX8|4Ok1IvRkBhkUHI*)YbwJUpE0kzAiRW{wEtKi{6RV{ikp7cCIEyp zIbB(IEQD`bIyU%can9;1-mIr>I+p;DJ9Ns#5ees6?{ucH5Piib$V1h_!Drh??5s*=D*G zJCAz47E-G;1cu8|Xd@Z|Y@zi_u%@UXle8V$ijnzCF)Y&KU*2ttfFYyF_cY+u znPKS%yuxpNxJS7;AAib`|7c%y_=7RGKu9L4CE?bZaycsrw$s297*O|>+JwqEx#Z76 zXxlAGB5LnR^@`U){P@H4`afF`*>lhw1isdnp}}fdKU+Clag1%N@!`tNt|l0 z$Or4KP~yIX_jA5w*@p#x>sem>>e>qR+mr-kx7kN?6D>}4lb@b~%1dtsI(^|-)K7>k zN!`&OvtBSsehteQtE+H`m`iBRgv0dNEztuBMO~+#0ZRX=oH|&Y*-lMiAeKoVX5IZN ze|0)a2F)J#QC;dPjHj;5%pGB~zN6{q=sOd?Z{CVNzQviEzyLlcD~w)6lZ)1zh3km~ zvF31|IvC%1>and3U_j#!_TQE$C;_-gY!XK;mtyYfDWsbpEIM{~+c(vWlI_JB`Y|sT zT-Q^H5z3~2y_ND%YywF=Gu`;RmZ3)FT?J7JQ83FrmR#2p746?-xvrj?Md7lOpANzV z1zm1D`DWIVK9!cGQb0-qvpeca{!qw72`7_RQlN5hD^of#XWA|Jl}o_6Z3u0h$2Vv_ zlBAj8N-|s9dtww*!~tYWy88Z5LB+$(#$82|w^qhCVU^&wFD#L8qTzKD2GO%AgFc%BOSK% zp#0LRx}eV<#V-tC!1dDx;Nfy?)Ef@s=@a9( z35P!a!ztoyct`j&#a=c11-)LiFT*Vb+b^2+B^l}e-s$I~<^HfXj`+!6&OS$qF;LbwfVfqp9 zkZd*!*QVt>oODLiW+Ti`8z(t3Hn~qy5G5}Mtt<*qgu36vQ@5o8>1G5h1t(pXDpSB$W11ze!hzR zJvW`rPDHb1@u?x>8+feLyd`ZZq;seLF{t!;WTp7X^_h&c2x!^_6OY-(u=yT>d{6v{ z=nE?OAcn)DeUwRyYw@vl9;5d|Ny22u$=)CLx4+4t`J&pIi?cE=f}0JJ(cf;1Z2vQc zyy;p$blcev>Oub%cUmQmS2cUM<`<@_>^|R0a58OJ0qlySn5vK?@N(nZDkSHxnalVl zCJ`gGw)C+-@4a-xBqSQ4SGsn7`0A|8BmoBaro zbk1N@$t_F7Iehbyk9WK0_?pl6SWOqszJTAVSuIn*%QKfGoSzzmtr;jOpN5l(gTOCr z#z!%wfWmJP1mbj@nO`7Psk?-(6t@G}mgs~;z=Yio!pH_bbU|bV6ONy%l0ck32O#l? zR~9e_$)p6z&K6?0l2PV~C8_NjwH}!+;A$}a zT;8o~DgZv!7fCti4`Go{shXpP6o-OU{W7ZMkyQBVcB7GJ;t;z4s+i;x^{19@pGTij zC2-xpwXSkLiC|osTIM4?RWd>QHxR96boXk`+8~*WQ!*;s?p!1a$_67Z{?j9#HTEU* z#OnL)m2|hQ-Mzw?($c)Enz#6h@nN_p7#c2`=BOvq8Aq5$+l~P@ zL-3+;BCPQhFrC01z<|{=U<_~p_ZVu*%();Ezi~ODp==(iM^2sgdq6fn4Dq=lIZ}0v zijgpwy2^8a`p@J~EZSq0R)T-w!{4{W_8$s7kA2_7Zt5NTT)@m+Rdp1QH+en#oYLqc zUH6|RM@VPE7?lHld@a*V8M&tehXDsoF*tmi%)QJwlH*!|UQz!X!L-P)ySR0lf)Er0 zwTF;^P0Hi0Kg~EEgwTx}i402b5emNZ6&aC0(-tDqhsEx>n*Buv#W0?b{BYtUTUbI{ zYN)Rdy&)CGpB(TG#3n)w!o-HT=OkA?Yl>GoNMC(4dp9mFOYotbOHl4z1k%PF{d}5W zH@sg1{foeN@#(Jb-AdAl`CQ^2Vr@1Nsn%Zm9lQlM^^<7E7ywL#pjn6HELq^GUhSzQKA zr7y4rxT=u5V68k(y?S22v+JEf7w-ypY(2(dV3=Pj4h_=F2 z@sZ*eYeT8XKvDTyPrdX^#ClI56N>6Tj@FUdOI6+%)oUGOB$ES!Q%I3nrtZjd8aBQ# zmTi@Ns6rTUTeVdj^N=(r#jtdnMa-`9O$y2$P@Z>QbtyD!(29ApceK7Oo5r%PoqAEB zR8Sxdn|$EqL7!(Etr4{&SX6?4ckpowSByR^hZ^Pg5xCr}^8NgiBqmDA6X;iq2Y@-hOR^ya1XB$O`;yP^y`9Yd7EI_*(~_o)24RSgBD`cBRib55 z{ca^`{zz3yyNTY_820{}~G@?_UpxC3@E>Z9cSbDk0 zA>PQ6hL=&vjv;(Md|SzLtFW*qA03Ztt`zqIjh7Ky}>ub;ott&~n`B3pwcP<#~TA!xZ|%U!@v>rLco;<$gr^^i+RYUs|D-KTe28x!2p)ZA<{7$S3nfnr7 zX5D`LxDk3s)t&i*xKhxMl`a=K*oB_O!@t_*#Nyr$xIIiWy8kBix?-gB?5O$d=*8wt z5=es&*Iv~dJD}@%-VX#OPyx{mQ=MJUoZ>M^tz8f2Gc|JBaFvtPRUQmuUk9 zn0t(J`$$Potf4$1`Tlq8S%hp)048`^uOgk{ge+qdnKn;3)d-o@H8;$JS}=GcBvPL- zst)ntFXdiwFkC`)VZscR7^zYj0@@Z2!>yI^T0q@&U&G}}6noczs;GxVg=#JFtc&XG zGCLgG5xEUl-VPT)Nzkcy*eM35QZkxe%RZIu62P`G0Aim<dbbAZAj~GV1fY|NW8ZX!Fe@Z~nbmdYJ|mZp$={&HDuR;7 zH#ptN6wTGyMC%xPIDq4B18jyjYw3IPsiMo?lwaM7(-c!d3 z4tHiu7l=^-dCU!H@hh{6>qfpP2s_VzbzX#+9Jaj!=|v zqptMo)9%3QYmspj@3iC-`tg%*l}tf`?H#gg3$N*iA_Q@Qs@^im(jR_NG1vU&Npbk) zb9J3FO7bT4O9a!^KdC_r-D(&Mz5Nj@fhRUmbWbE&(wnS(u4wk_Nz89<-$f27hJpIV zz3ASrH?a67x7~Zr)9}YSPR@=79zfM^FftlzO}b)=v?@p zZ!>!2++l(0^))exBjWp!b%@F$&2pWz+($+_A~eU9zr#~{_bw5+4fh3h3Yeo57XqNNH>O3+qdUH49R z-kapp(LbuW75G287q?e!2U9Mi{`O#!OP-=*kG{jb?^>6{uWl`jF1fLQpPGM|7C}KS zlNCpgRjBRof*|pKd|z3zxBbL3F|`ZJ3PiR*-b6xRQ_N$QTYq*@q{%c`x7Q+ zB*SclrZ@b}bBH->7^ySJ4Xff!FUuh9A1}`$jPgD1`V2Wz94Y4^bhP%^~}!2K?I zW|jpA;?@8J7!nO6XAKoV*)fl7e&3N%{apS;!nVCbYX^M6)zHQBF)6;WrRVat^> zkz8Lqj+-ZmFFrWO?E)RJtEQOPD1kfp6Lo-&w1;(UExchS zoRM~}PpJ3>(q`w<#!`6mfp(itg3v8>ytLa}v~9;fRXB`wwadFk@RZ}}#oEpWt9(pV zRt|(Yg!W%mjXP;?A~0%Sl4F&$!n%DfZ~yvYOx91ox5>?h>;HF>$w6W z%3jtyM}gbwry;Dbs>y4FG1?2e@-gGp1+#`%V!R*${mM2ZZZQ`6Z5KkXnx-rJ(WDYm zu+(%YOs(A=N`EB&MimCq>>97Te(3{_k@>{@u9O90yY1lT4~pafG8R&9yw(5|j-)sy z3CH9OW9aPTV&{KqdGxT}I7+F`YBxsg-l3;vMf7i3f82c&Gc2x}5r_&tBb`kPBUBmj zB24|UuhiPKPE_SZfD>xF?FDCitFqWm|L2`YW#jtKbd^g5!k>#uid&+et3d`^;ORqP#zLY^Nx zdhq9k;yw#`B2kn^VKc&R$hZT4O&EMxC1_2;9#h|#&-G}Y*>F0}f|bTY6$LK%V^wb# zU+Rrc&kbg(oSVDTc7g@3EL;m7n$qlkhQ3Y;w+7kUx%D0iG@o%|Yg7mDg1o8I06`1BPat+&Rv3ABIu3d#zYwe;6xGqNK6L>FM7 zb%|PK!?$`7ny&=w186->yR$xeFhZ{JUzhsGudR~G$sE~tb|Ac^B`!pyrH{!wy_Q;+ z=Frsn&tc=EC^bN42j+PAR_2brH|1p)J@oOZr{23n*xVwNKLluUu{PYWA=nUTo1lG< z}eU>SR)cN~jMHiyXDRTXv z{Pa*o@;+BcAC2!BDcDajIr{3~#0(o-{U=I12J>pRoJq5-`w$vePbEkEJi+^pR8sI& zikMWT=%o(Lyg0*=5g%H3nkorojF+gU)D}*{OMGl?=rF+eX8Oo-X+3>IaB8@M3)q}_ zsu%^^;M2fOcM5{jrV_HK6WBV>Fsl*-lf~*g1z(Er1}QkBDR2+?v*!JJ=|77gL171AXy>sq!uIqhG zkOD^N3(@s?ET9GhqzS7=Jz$s^MxEF>S3LiULlpF?uV~!tLLkQzmAT=p^ki;;!@$TZN?_zl)?e?6`Zmx z-6hv+$T{8=wAIZ_84+i(w#54)fAKJv9`L?I@=jJ%`?By}fRrfTWswrzDyYw?VS3j=Ld=hC&lmX^)r(01Jvk+sRwetym(D;I`L$>*XOE$G|jKVr7i(Y%Rm zE!Qx~e#I)+I@D{T8Sbaox4r*_5a16yGl`i}jtT)1;wOHOD2xw`>`vx*>tFMw ziw5#mor2SihB5DM1j-9+c5lk)k^sUVL3nu+mOPB^Ou)bJFchG*z~iy^O2cEGpvadx z;7!PNeUMZ@z_Z`vMc4NDTR==pkb3lA%;d{O5=SChwT&>;usB9ez^h2ao=75yl0hP> zYf}jv)VY?+eF^iVI$PFuU8i5bWrloq=}!Ll#zmE^+_WaiSlu+^h2Hddci;VW6%(29 z9+gx4k}ogL^Y31rWGLJ0z+qqe9lhQAFwnv|Liglt>R=?juN;6SZ5lDU{=tLNaPs$F zQS%oKpEpRFY%)j57!#+l4xY1zdxdftN~yamryBpcKSR6+!*vl4F+7+sUxI4ee^HV~ zJ$a?|89MtXkbyznYN=So>5e2e>+@FQJu}6>(=uy86jwj526bJO}?wk{GyxA zIs8(%o5#Yk`F-;2X3NMjN*X$w(%QbUd?KgDR7|L$I9YGRC^gJDK#dGFC3e|yi~$P- znoj{X3q`S^*p4$tyxFG88lzk(i68tMxsXwfy(aX24&cnWKq$-{+p@D>yE~}grH)M_ z9xtTY*`KpWirq52e;Xdzmb`~30OIhseCbB~*#3zUPM+n+dWSq?8%xm+s)bo$Ng!OI zBPL(6Qadrp;-oZ@HOoqsV7ti5ssyh;=d5mLedj&Y$0-YFbNRu zpFREP$B;ecgx}RUG)9hb9TcFWDpWxi)%A{7+WUIqYZkAxoG#WNma4RZHsT=sQ$aly z(S9ov(Wld9;ZeJ!-m#Z>bqrx&FvAnePTOMPq*8($e7#SmQ~pSDzj!!Yj;yg}aQLXv zWWzi+%vNXPigaIlO_3H5VGC73*S>7X0-W7RCxg2ZfBjsBg^oBbHrN$VQWAvtt|z2XSb;*}9+k*P)LeATa(<*!P8Qg1?NB{{_tFfc62&eQnqj=pPLsvzR>( zTguEa#SvzQoMtQ?U7&@f1J*g%5V-k@00cn17>${`}g-3Gj; zh3o(Z%J3ErH*!hn>aUKc^AZ@H+PB%P&84~B2_^h1_2)zhrK>WK6Ng6C<9&T@XL8B$ z`FL7M(JDQJPBBHZs00EDO)v!5-IIM@&@fl{Bfw!{|{A{@61#2X$+Qt)Ak$e3o-vsgj~IVOG(UlCC@cU9pM6 z&)2E}saVdQMZ@aPD(;~wm$go(C$D`tYDdhA!rgl}wj&G$fLVhLzz+oea4HF;Ks(`V z-#SWE;Q%-B-Fiskwm=RE9CG2{GMgwZgQdyo9mpc<5vSUiNs94tBxr5VUaDX zDiV0n`b^?1>P7T@0q$|qnxq4D`O;+^CSTTj`xS!5@*rx|ch<-FN+6ZncdG!N_Y(Lw zdkda-$uvg36nWk71%DH9JQjJTL+Ef$Nq2C|NNhPXPN6S=H-XEPGfiq4P-z||N6-Et zeGX$k%VhufOhPD_^%8B-$r_ozkbR`{(1nc=CfaPPWP6eNHx{+?hF02Pqjvv&G}qco zfg>ls#hcV3KlZZgPxd+QGYJirAGT=Q53R`&pILFeU4DOC819ViS`^e(b`7K7i~sot zv6wj_a?6L1(E|uOzhnJW`Cvc#Q-<$WI#zGDuHm#k<*dXelfx#-iGLTb?a{v;o)A%% zq!yMCC%xm?p<4X9!ILa@c%gDy z$bxpNP&wg5QY>Ql<4iVnCT2HcTCrTEsEIP`?_u3=uhCWGqq%A%VyUu3H8T-q?^)~359m!6GRbl0hAuQLr2E^YAfsp#d;wOG08bu*e|KXQso^Az^01Kvp z-|$n%CD@f}*^*h)?3@Qp19g9Ldn#1)l8=ItYp8CRR1oMv&EZO@@fg*LbD!D>0ln$c zhX5@OLm0e?)ELMMgQy?3EbNohe`3v#SRT4|qR?^MYuNIo=cs)?>Wr@-y z?TikmE9Rq=N@TDVQ6{_Y^PO*%wJI?YZ@6Il+gvmW7ruqC6^-Z^zfQ4R30nD+;Gesn zp+xR|8~LCTSBm8E`eIgO9N_-#6A=2tSBBY0F_eWL<1>LA)MBWRUo3m9nR&6NG2YE< zzgR0vys=XIGaEk2M#?kQ(=&z|SX*eM2#qD&9^KNGKhOo|rE1Era7W`&SWWv|ThHfY zCg)l>qu)i~`X=X4KwerI8;@S=LXov&pzM>XA|6m-Ajz4;g07%Gn>zu|q3?9-aec;x z{dwcbqQ&9RC)Ee%`AxX=`X_XBHK9~3e-Kg6vC4Ih7o1~BOHo0`A$5|s;Fh-qyuLq~ z>ptHwyd%L8{Fr&^XXN-nO1vhLH$HO~>=;Pj#PEAwok}SkWC1Pv^k(v*cPxC{5h~~O zy@8Re__y{4`jbV}?F8cVn&;A3M4EQXGI{SnovHeak8sAdnDg<=gwG=iv9>{}&KbX1 zQZ=VTM#Bd$Ef4go`z zb7ZnI5p712zf%rz6)Y>3=_gUbpODv;& z%9YFu$hE$M@|F8O+lg39(_Q=%MrWAez*cL=noRj+f$beF`H@UU?_GKoSKDrkl;35? zFx$54?5*1rA|>JEBh)e6j_zMU?>6^anPf;|Z%=-da?-jv;E?ZF?a{flU!Kf1Y3_iq z9t8h#@8&fB>4YgbcW7pq4Z`?3<MNn4)u7WTB6viNc-{TIYiQvo-f6jNu6 z`@#IKq{vXo7FR^?Y!g>sI8J*0fo}7G zpE{<%ZtfA^M3^|)Q6Np436ZV1yWFZr@Q=umOJ`BSyD>`qar<{C5A4pk!;LVa=m|3U zkPZajw7wM8Z-_ud9)8-Oq8&lXy9qfs>9UHg~bx zko_H{1L#O>Suyq?RlyT#^p;CJj#Lxv52Gr{RpNi6!#uz$@gLAU+vf}<&Vu4(cece! zXWXNaUy4)$hte8Ae^A+#Rcq z9{9asHmuWM{dtDOO2%Q7%%P3P(&3T;qV3a)O`p+wr&E*Op!7gm34u)vxt1&lcq@%Sug+ zVxIi)<=@9S&%X-=oo;gVjbWtUQ%g9dqnxGqhz15VpsYHK;<~Ht&;KO?j*126i zCO;qb9$C&v{Pdjc?jNqYxEqgX^X`9DslM__sNw@)8z;S!jg8`xx2s=XD}<8i3o31F zt#|+E_8HfUIx*mIYTsqcbs$|-yYW^~xSPOVt-a^f&usTO&qAKPzyifSb)j7bG4#MA zMQh-g9J^*x>Gt3!huE1l{0b+>5&?O&q*ke7egP5%hWo(;UR zE-!}q%gLOHeP5DfI$@X}PsZ?10C@pM{w?vMK)SEqxQ!27pf2K#oe#?WDNl%uL{@DrMf=;FX**16Q`Xl!0kTZ(d5qJ zB$*liBQJ}8YG?}DZ@J1A2fqeNL7O#RuYC{noA;O~CJ>Be5dzQr>6%`6$eFoSBy0C~ z*>Bvu?*{|!fs);7v}U^SV4W%PHEgX8XBjBa2ZP}8y;UT{enk~DzQIq-lW7Tx$&kMQ z7QZ62sN29VG$Y(eh%>t)_aBIeP(A-J`cgw*?&#YbzcS>PBvQ3+X%3xc7yI$2cgdFx zS3Lj)>Hq8oUPnYtgax`81^j^2;w9rn9H#Pkd1#tc{)$Ue`^YToTtxi(Eg_f)TV=@+ z4B6~awxH!b>P}q%MRRJr+Va zJ`#KYc1Nk+-FJgo6;)3n-ax)cMX6ujR;;LJo~QESOrvYodB6lzJ+ix`StN&%`DN5h z=Rbo%V(AQaMN%I?f3Dn%*l-ll7i@(B##rEOk*6{kvpoc750ariuQHE8$&-KBm6)`# zKovGm))i{9!M4L0+L7BWv);=O4rA`QS1sW(4-IwJ%l1&0@F(z>@whhBGyUlUe}nc- z^}3oH(BI7hbjx4=`PneiZ@(wtk8ms|kGu4G8*vdqb(y;#o&oxC@4g#z>KIM(Yd}ji zw{~DH)Ob=4;WB<`lclEu*51m1nyQd`&2d1>;|8QxNP}CSbVZX)FWT0S1hKnuGY)A? zc?WXx0XD9g)R|5~c8`;}gf_oy9*(>eS>Q#OtB5KF6PzXm!^xjmmj5SGcu77B@o$ha$D~$x@+~t*oLoi z;@Xn#R-U10ZS39hIzt35>wmj(_0?EfgbN|MCPLly6vaW)MYH)IAvCi)3@qp|8@3-dt zz31t|12th3k_R-kp?KV!>V+3<3W*0 z;k+!e?4v4Ak}VffWZ&9WLyd z>BioOf8!Fu<7|bFDta3MdLtMquTBHNGmS3%xL?75t}cxha6N8nqGhqKf{e`N7~@8< z5u{%s-g^KHmhjHlBj(pFJQJNn=hx=kbKl#nB}bVFT&Frx5+K**%<4EIJ@lmVNqKL< zD%3EzcxvdQIt%@&1m2a)L%MEpci zNt78emOa^Ajq*tBO8zuSAWXfKbb{`z1gjxG9r0C?sYmY)Ynw1yJU(LhFUd*g@lWM# z_A^?)hSzlk^ZDreYU3P6mg^gohAN*ZK3sheDDaE&7EIm%uCNo7BxwOJ{Xl4WIlPIS zmmL26abk4w&pO7>nttD#hdHka&k{I&_xTx-&-RBR)YF*XU8jNpGG_#r8uDx5V9F34 zSqFk5Od#IvjVwFx+s@EgH@zn==O#T#pB@U8btdiGVCFefA^eBlEA;H7D&H9@l=?8v zYhg9bWkv5HDz9nX9N~*&O@!EWvXtlR7n%yN0n3cG=|U?1<9Ziv98$WbN(xy{(?|ohMHN!jnlDA>i7*cyf_bvn<645!4IFeX; zXwd)B0N^oj_9K!}D^Lax()WlAo4gt7wdi7>&Bu@5U$e#*-kstg*0Y9!B#l3{p8Dk z)Bg*e5JlLwOLdD;mbSarw}@6N35-|Xs|8kk!SyTPl!I}xQGCPGxYX7cN8a<=E6XSJ z1cD968JIrEODm^f>_&os2-_F9Lfft*f>mDp2-q8a3&tJ8s7X9$2_;X+lVdj1uIfutk zGkj`LJR$+NinuuLx(xoaCM}oLZ=7A$uthpxLg${N z$#2ODklhfVHfEwo>X0u$|1eUoFknmvU~r5X;YQt_?Y-oL1n_t?JvtJ+EE)`w8O;l^ zI9N;~n`^G*KdM+O_5(1E?^*1%acGm}$H?T-t?_G0ZSIwNQSG2`Ebi?0ZlFvEsiNy+^Y!{58e1jaGKe(7x76U27>g9O|M z1t%be*DZV#&DlgmpOW{Z%uqUOYk&DbxUyCSr+~?wbZ)3+Q|6#;Z~>S=vUt316K-|> zC|Lv(oq&5BH6cT)QG#EIg>Y(ozU6GX%15spB|*|}#Xk_l(HQK-Sax0=G0TMPQKr<>r#>5>av;;V56Dk|2A885vWTobIK@jr@lI4; zOMZ6bHhd+U&<5fqnhZ7Q!}L-d^B-V#cefkZAPv`af4kZYqNfUgPkyKQP_t@-**LK6 zW*c06)%!b4KDq1%w_PI(?MLa?g;VY_FCMQGwwIeO{Ill5h`;@G?rGg&%c{9nwJN~MBojS6!F`7) zSQ#7E1HpGAW$7w}O_Sx!+VSHq#{gOU;vP@$csb8eXBt8o%Tn9ZvcgWM=SUummLl%gb@V}bXR;b@2$UT z5xx0wsEpZr_?^SD{bIS`AyyEi!Si3DoEa+vEv)SJlir}E$jMOY;PCnA8w+_+Y^pUv zJII+e^UBQd0nZ)<8R*IDo!!IZU89v65yX(GD=v|(9oH+4srAG$=CC4q6BBE*<`I_H z72+L-Ps$2hoOoEUVabY9=Jl1^%6RE3qjfR=34$%7xb$`_?PgHs2oXgKsMf)UFd74g zhf~o7I=B6sGPLR}$3%ur6O@>&u4M%4pN-#Jim_AqGLXX(JDia8us>I06Lq#`#3fqvC{CTihtYcQe1h<(An5h*9yYA5VES zSdOjAdvcVB@-q#=Zetq3TB^zIfM>BTrS>^#*-%2yzfbvDtI|9Q&*Gm-G}%ydWxzkzLg$`QATjpg z0h`5+QoPZYrtHnZVEkG|TD}3$lP)b!fyvVE(%3l>Du5d)e1NV(z3=1<`o(|IP1hDM zmIel_VdZ6f5g$)kXQI6$2C}~Y{*a-4n<^kP@a^-ICKiLeDE&kFEXl`FZjB@yPt1#| zu6P@eM>VX)?Rw( z7$c;u^4tKz10YyUP-)d{R^i0Q2>7D)VnadbZO#RLn?@AD@k)CxnEtGj;r=BY@~!+N z_tHL{c)o4vgZK|VN2o~%)y+8DysFVoRn;G|e<-~8CV+$@>O~|R&G{7IUhPQYxFkDu zZqso6t#+wqxN+ODf2RKCUteOF?ZA#3(>X-txqqmM`N&XZG~>OVQ~anhuNSQigBB52j^(1H{krNZQgP;@ul zN^g?DI{Jz;9vZodBlM4R!t~qB$_{-CH~+=I7wvC;7-Y(C;qs4e;%!)_LZBywiz{uP z+m5nd_b+T=umAB^vSu(&;-by@KDxE%FULb_PWAU})dyMpfvXe4(b2(EVtZ+)R^*u!EwExrg7`Qs@*0)1+|MmQq!cH4cK zui!obc82As+$2W+?Wo)9%2Ja^p(nh2gzO?5P*xC>jSMF9mX8k(WlvBAmyX5#`AwQr z5Au8mG?CLFjR2pGa3TdL8V+@WE;t!}e<_@L3E{)k!r~7fQCz5gD+RFMIroFDNTHw= z0X;BkyMUjlFOr`~A+kQ`5T@1h4Z0_gr%t`~jT8WSTmyoF!y#ut$T=``^O^qJz2FC} z7e%9Cssy+H%~4kRaJt3Nf@-2F{hU%Yiz=qxy^=apQl?AZjCZTn5_T0K!HwbHIz=q* z%ME{bV&fwuV`m^hf{~`e0WUOZ2iSf7X*^$nCU)$(AU*H7@t{~5N+azrSiJMHcQ14Kw78h=r(O-&Mj(|;XbG2PZ zwCjGo&QqGMkSXxte6I{{wtyprik+AVjsZMXzo{D6pPI%}ES z_3+1}+&*%$HR-+J$!Ry!%{$DbjCQv+jh+hOdXsBiayRqfFxavTzP3yF)MP9Aeq;0w zw)wu)nU2cLi-<0t`vFTlljU35UPf_)tTBQKAi@N|r!|*N)r0Af&vrKvnF?_XA;lf~ zq8Fp{OJd8Z-+Lgfp0G*|vOO7GbkH&v{MVU8^+H$NlS_OS^&Lz30xn`qs5_P?YC@lr zGYhRw8<{dFN6H#@PfT`j43!>F;7Av65e$y`#*>M6%gYk5zLKXHI8B>;rH=nAQRji& zJMG@^oN&Nj4blK*F;zsE=Io z>Qg2az3OyEMmcN|`oa3oO0Tx6NVGb*LTr%(&juvEv*DXLVgJQc;AP~Za`bq$`Y_>KXQN)+P^3t!c@vpb`;vBggSMZ0t#nQ`Tc$Yog{ zE0%AB_tKr4loP5>y@ICUks%B)ha(l^Nj}}Pbr-}m#=jfOxy+J$kvAYrO>`4Pxvu41 z_i8ZOtc34~!hTV{NZ3o2{`fYb%G??0gFp6Ge|a@v1xh&_2_Zr+@>9AP)>ho1D-|X3 zo~(<8)+|?FGX(qcOjgtw^D{?|_{}4PvXS|%7!e;K^JwKV>iii)p9+&`u^^R2!}V3e ze^EalP&D#T+qg>8Ni-W6duVIkh&Cy3K8=WBPbh&&Gw}Yo7vDx~^TgVZC*Qqrri*z% zQQ0!Pk-9HT$|e3Xs41jifM)rWeS>A5tH&a3ufji&|3LkSH})z8W`Ym=7FVJY^R*ef zG9SUXODh8;fk%C%)H11nd;eAYFORokMdg7QpdKMA309T_2DOK)esYj6UJ4UZA{#H9 z4`U=WrMlZmqA?M|A@Ye8(wo!waE)_3b7ne#AfN*yJ&yV7my-TY`hl7};lKke*MmL- zLsVqi2NF zEL1;%vPALL8SNoM2>MkB=H(;@=SZ2{DrC_vrAMEOCjy?WqZGsB=KaxIB2k7vVX60Q+T4&cpL8St%2}zO0}gvY^)fkO@U+MIxV0j zQrYGD2+GQ`FNygWs7RsOy`F~oaB+_mpiFo5g)=&Q(v*gHnd}d0ft#X@qc=+kFKnZj zU7gc9m0O!Zw z^KTa`#{Lc55BS+7d{}j_NT910P&2>iI^h9>_$!)B0N?%T?(M+nXIohLjrHt+`$2iT`NeDKGGAgZVVJa6h$Sd9=mIwUC`W+YXh# zb@&aFl0?95%Kx!vP2c4Pa^3Z*ZyGJi2H~U0%zG%S^)T+n;gL|S`9Zww&QFe6*uz5` zheaY4HO%sB5B8&Nos}sTT)*m%t|7USaaYs5A6bs!GEbTRB*v6cyBc19vwIjuyZ5QP zXE4@Tx@<$*O~_}rqjpkI_09&A}b|d3v;n+{+}H1s}n-nBO$bDVSx(g_(naR zRNMP@k=*>ZTd7?RY?>Zu0jeruNNBNl0yU1A#S|Jw*k~_) za*=r=N(3YhW2JCwt9eLAp!amL>_@rABnob;a;y>46(Yj%`E_1{EE(yf&;vAT8W7Q{ z^{_x-<^7E*(~jk~!+KHUt6uazK!*`85X7qh1t}sFp)t&Tx!rWL#c~Fp6u}{v>yrz^ z-_v)k+-~W^u#a>!EOj_C2t6dAlxhI&E!W8}`>qb^1h~IplWFFNz4J**6+-xt#3W@W zWBckCZAhSU6W>8(crSU~xC-S$UFW27JdHu*phw)GTihUOhGq0xZqFRLd<`L9Q!>d8tt617c)9YLYWB@~# zM9gM^p4)IlcPP>bt7#pJdhosPv1=hCrGKHn8j(riaes!74 zwjUu1ujqVqef_oyj`ufl=+*23m%jLlDceq3xd>^WQky;(uy#oqDgR(fdDirej@xgC z;Rz4@qE6#8ub+h@UkQ_vaKm)hCZhs1INm}1B^q6v_dQY#Dc@6gHUNg-1BguhJLLD@ zV&e#>{5&3q(oegYp5^&qBpG^2Ys5EMkmU1QB^rEY?ar5}HyF~$a&>E6(UYSR!&qKx z%Wz`~Gtcc*ZlkX0C-o6LUNib2%z3+Fz)I~ttzZKd4%&&}^OVgsbVcROQ0n_pvx~T9 zHKFqr=mfPT*H1pjg7m;y-`j-o9u(mCt;Jz0%lDeElYc`9AEtP%Hob|H52E_S5ohtd zn|AZj?IUbNM1CB!{}lAQa{^v>8RP^$`*o~HaX}B%*i;PM2n0#F1YGeBZgmsV18Fk= z@sBWBc(exKHEcExCcV+?2Id)!E4o`BI|N z<#PyU7~`9s$Ec?q(LS?`Dy%R(2In6?kQ;|<=G`*qK?H4dYi-#uw6!!^nZ$oi0jw@mgu z1gPgql}^8aa;tLhCo*3a^Ia^azWaOruKR7Wf|@0bcFt1+gAyUf65PPj?EI8mkD2$-PFHiD!g# zGf{h%j+Xt8<3+qgNNe(IFblBoRdVE#Vk|viNn|pl0=<$0}=04Ls zJM)p)-S_dX5Fyfs&&DyAW+T?o%Ndt0*Fo1`9ImjR72Izt9KP&6BC;O9CdmqkUm~c! zts;7S{?$Sc@FCD)>U};dwpq>?>SPH%g{;QP9R3sYRZ$vO;-*3Ir&2{_;_;+Fj|{&y zO5#0|0^@y0vZhQ&lA@z~;O4J(#CoO&R$%^B&*j+6H&Q*}Cy13R&~VW8<5K4^zGpv8 zXUa@*HmN%OKl&^4$oC0H z35^y0beq=MV>R^phc1QcoHeD#KKvMXhDW|umWz5Iy z_rKWSf8#Qssm}{aAL3E=lx+k;*-qg1_2Bn(isWCX!M%C`bB-PXi;2WrpFRARVl?yJ zp$hn`1^L4WL7pad3@GUd+ zKAsinSMZeGH5owDQ_zo7$|E4oOHIH@#H!3E$0Qr@>>>Ov`HpG>=xBWi=&!AdV489V z+SM~{34m6j?@PDRDmY!iN2I0B<5s}91{?1GoWJt}9t$%%+UIR-|9Nld!GEy(zti@dq8iZfjjy%ZX@`3FReA*BXuNB#LR857UVfQ_cRD*NWI3hO@0Q%70VUWnn$N^wt$SmG+*Z#` zeM;X%$on&a9=-1=XajVO1s%DKMF{z;g+4M~X{?P@4^a)hVDxb_(X_W>tA2W~Ba_sf zYR&5Lz~Lt_ARik0yHGwDX2Jq?{f~{p#>!RrmZN)Cj)o32*%n+hu@gP68HmRYn;?5s z(`bQ%XbSuqA4VY+E&U2F5wPWM{^wMB|G0xV8SKhQ@7K%fr1KJq|IX9u`7bVfKVy=* zi{z$7d$Qh-53g&D4L|xU4ogjmyr58~_;b{UukIY}X|L)nx^MDHir7wtqo>R@v;2t5 zzd6O-?-^1dBD{oc$1PJjE@%?pXyIoN7AiFkjCy;zq<|BXA=*$v&vq% z0agxA+~4MOJkVcs8sE*>eLFDX=Sb`!@?6po7;6%9{sjGcWdiDmV-uIZBVO++fQJ&- z{X;;M{H)8K<$)5g8AWoXT3(}Wx!r?nvG;kvrdABUo2)$E?D#&qy6PyaL@M==h1t^v zq^WIxnr(RS?L(wk;qmu~`asGC8+G8&aX0VjCGvJ17qyqd4F9(uH9PtoYG=t(WsJ!j z;t9{4nHYVZ_~z}}^?YmPprh|NmMh5Q;gH>9{-5v9xIW>ku3CP zNYy^7U5C8KN_+yiyh#_-U+f!A714D1^JvF$pmEyUP?lCGau-+q;ERJdPTlxA%#5;y zxVAUBn0mGOyJlh#ApLuD9VU86S;fqmMc-brJBB;86Sz;Yh0#lNDQ8pR z8KwA1{0L*}&c%+wiaKRD-7!9wDtp{{!a zSCG@m*=BNQ9*bambf!**)lS2ai43RuD<|eETk3RS@%7v9K&$MKi9$Fya%UjO;K7=# zBCv2~D9+K?f-wj}gx?If zRPfzEDgDXshc#XjP4}q@w;$GZ+vrV}6)AoK|-_&Ho>At4D zM|PYfV{;hLx1ME~$`PB%7aUQx5DsMRBoJ^`fCrK%&;xicYYo8~8JRXT7yb6~EQ0DK%sBp+YIX$(Ti>PBHvwGqb&$1jXkTySI45`Xgd)y=Y&*3dv#~q3sXU18?zb&}BOU&n!znMT%OctQ?CS}m= zu;RC!TzY@~3le>H-6)P#-Z0?{yzg?-{lh-UO7RK!_aF1ZpCM9Y!UXV}rw`nh`$++U zCkUn}6>~W15`VybR<^cu04jYy0VY~UD&-r91fB@(NeerBj$c6h$Ca93%n|TCHr}Js zK$_eLA6#dilb|%gQ3-3uu~}|*Bc>6!Ot93f{<&h&Cz?dl4!W(b-|P?I`I& z>F%83H}`qC+1mv}@yizaQXlFiqV?z-&6s_p#;gnB{(C-{Gst1sVMmn#@_tABc;Cy&$s?PJRs;;&IPQDI3+^`GtrsTWHN7?p87^TFrs!#;9KbU ztT@>=sPX>C0GS~m;NW;NL`eEb9!06~nu;Rr8oKF7<=d(I}v<)^yo_#n&4fPeb@4YdENg=KQ2$R zW(;X@uDAMH7|>*{7u~6ET&|So=PIgg8_L>d5VbYtFW9`J?i@X-Q8(PP zeGjhuR;14x&mL<6goP4MJ_N5BzOkZCcF3FlKzleKEVA>##Bt>C*tB+3 z4(M;Z93|wdMECt|U3MaTCN2w`rBk%%V(+I<$JgK~E8KJm3+xes>}A|Lw?uK(oW9q= zxjrf`m}9v*y9LJ1ki zKbzQ^YX%wI9h&D+as9ZauUHlW-9cJO_Y+FY1^;vsUh$6#p$_TDeUDrW_ca1tNcKtj z;9n^9@B7{*^syZIqqrhX7GP%J_&NNN->AKra%WbvF59SZPMKw^cJ9{h8DOpJheR`E zH=If$<#uwdjg4UO(iOP}YzpSos@l_RYUAwDz^zx-Imew;he7rQKr2M}W<5!B$zA7Y z3oS)UfG5OgXeI+*1AmZr>*wtmVGHPhH2y$-Mhz;8U@7rI2f-;GLn0!vhHdh+jL8_K zL-+$FgEIuZXr6r0sHP73c_j}>gP&O8YB@3Z5H8z#fJRT!3y=Luv|!r8X5-Wca1;NN z-HME>Go}H2_F+02v_ZhuS4H!U%mW9V9wQ-iaz$Q@NXdy1$p$`OS?byKQO=aXbRpV+ z?|*X`a(>W}Ncc}p7vJChH*Ci79gIBN;4v_&q;2yO8AHJSVYoNR6fVX5<4exrqor#?Fgx>`|=i4eeyJR{}&g$dVabR|vgaVJW%c$SRz7CU8L9zP&Y z9D-UhUBr`wtzqErkvy`@<=ddF6Xw3mZqP^{XVi#(M1HHL;MSGEr0oL8ARq87+2JMB zOp&Uuu#bj#sTWzGgS{z=W4U6B2jF)fC7jVUxA1L!RS+f%zd64ZCP4JriKc3*|5F%C zGSl$#Z@}4av4H=qJZ{}CKqJsjVDYGxW}0@cSooUu$*1xfRV8gaf}r#cJDsGUCB$a%im3fxRTOKCMWCW= z)M0e_Usew-TW-&_=iodHK`0k!i8|tlS_6xb;uW!7eL4cB+`M~`;)*-Y9Mrmz%5*?< z*V{za>O_AmU2w_sCEnkyf0zF@-|~xPcx(lUO|wLes$bec)w1o9?L}t+p+-JnG;w1z zbE57voT2RG8cT1(2{_Kq`hz@^#MsP-Jul%CyUPUwm`2|_V06X{(9WWc z5%k*>ob(c_1v=*O&wweeg!C~uDs2^WnA=eheyRMVCK&j4!5n)_ziKELhP5NccA)Ax zceWI-8vV<^3UR0j0TfnV@#g4UnPi%~VBz(>5l)05kCpE|i%Nr+oc0)eY3BJe8yTx zY6?%p7G7{iM6jLf0Q_I^@BVX{EPS&S2nayLr-l$&JVPp$sq@JDi{reruhf$S@Fp)E zn%6nv6n9B3gn%HiQu_~~fo=1B?)`eAJmjDhz(Uvigb<-vjaDbBy@KNZ;{pl%DgbdP1f%ey6>gVX$`o z_%h)h&1nf!up{q(cX-qJ`~Zk`)2e7YHgi$fUFM`gz8+7AKSc6#tyZ7M&&zYChY4u? z@}eDcdZTe6O32|wm7@y^uF-EpJh#gm%!vsCliJ+ zQ`k&r7^5rf4)-09gD6nM8Sa91ax=h38E~nv-5Hh(0FOsYtB?$CheA%7dK{XFMfkX4 zq$_NmS*xswJpf7VkVVaTM-+o(Buy48?&`5uGpF_ z+HHOHQN?hZ{K%q15lAqXD^G+(Qi^ttfhd5DQ2z&RX#R1W;mcC*!m!dOq2*uQf4^LW z=Jjv&n&s^lMhL)Rk zDf0Ak$8P)ES1#>plY-Y=Wv^(h>$>-n6I3RbSc8FWv7 zY|WL24WQ36Qw>SlDonpB2*BkZSg4p<@I@859Z!yS;OGDbpNUu~*p}&HdIf1T=!6h> zf9V(W?6L5(pH-+|!K!N^>e@%v=+j;Ecm&l<_oWo!I3xVao0Uf&eT@Rdo~!le+ReHB zwtUwTA8yPY<$pq_+USGbIep$D^aXyQS@~V}>+Jbt8|QT{1^2XP9qNQ_PWK+Sao;;L zHtg+35}k^%*w?fiD7LMBhnDfCK$Ak55~@BdQeEig$91;~yqL=+<9zD@+`cc#U}Idb zyo8?!F#PZvgzdG*-4%W3wBh;e)&h%g9}zcDB$P=5a9HrA0f+f z{eK)?Wn9zkyZ?>R-5sMrI;3HvQIHaYkXF!#5>T3r?p8v95u#X>C?PSr87KF~|uKS8_iQd{pH(ihSI8e(AMe@evgpKtCDczWr8dLJ)nAXoIhkNnGcLzDU zxiFXL}bqF-DrX$j#9&lCLnAR=E*woGE7K_XHeEmq`{&^Ju*UzmXX zNXBq=oJoaznDeJ#B??s0KTyzpr^auWsGQ#vWCbJr=*_6vR3rDMZRbhJ{~UQWzJVb2 zK;8UFX-60xdF)x~O&sksC16mj9~R7a8C$Gx>@KAtG}GkbgyF{~dFjISCl{Dz3Yf2z zI?>&DR*^&;;nwXM27P8@`Gz46%JozCG6TiS6#(VC3Q%@Dm3z#bEKTuR!(F`@GBl3h z^$1e70wB~hybw8rG4aW1!i$gPFcChCGe3&?F{OchvZjtr^3@AFQh!y4ZO>EGGpQvM z%m{#i?&}3F5(MOoP))Ot-rQeQnXxbe!1Q-Pw4a~|&r%aT{kQBC1+%x2_PYUP{Y~7y zkxI&G38!}=aJ%4*S?v^ej{pS>d`ufWKkIV1P8X}{3k0$=Wd>NT2Z@0J4PX6S;b@%- z`|+EB6rc28)o+UB{zSmc?i}7feap;ar6*h$REZ^;eiB|tFgRq?Eq<4vaY#EFdm%yd zD1rG>pqYv->*u)5cd8~^w%&(L`A&HD3o%1Oms>6l`yjuy?@Da2`{SIA{sWKGi`l~B zf^)aWU4AQ;veLzG^H7gKV`qqR(2^i43FnXF5t)6s;9N|G|u zMWnzSC({w723Y1qEEXP9V?k(`pe91_KNo=?xk=Ya$CJjHZAbukj-M&9n4*su^qKmw z7p3z;bvGxdLnlPDUh?On14hxtiLC)7@hnzjSKaek;E65xo;3v{aWg`p5}c7|B=rc& zh@pt-o=DF*_}WF*nZ}MzKY>P17`bv)7Cx$1sL`ql;(Vt)sb(DU!tqMl{}eQuKmbo} zS1*RHw$~h7v;E89jlca#^K}{IvNU^0;5p&l)tRy%u($`hslxk=>GAE=1EGYOp;65-&=E9S}wS0!Hl>_N_L z(xkS0@BRRnqPq&{Wyb*XkqfqfCy`;x&9_*7FGMj844oxze0fnwhZ-;}RvkCqIhWph zU-kCvICRYf@ry)B^NH50Us?GIAcMYN6h1CFy!x~u?fp(e3Kl~VR2*Nt3o;u|8q$h*ta)CS) zKs>(P6vrQ!wvbX)8#dP9FaC_#ly20>4@KVZp5#Xgl>FCu9Q*fk$Mx?wj~8;7EQ+!} zN;FXhzZcAelEsB|S>>y;8eViQ`5)1x!Hu85nDKL#*Su4qN<8w7yK2qx2ksI3%xyd; zg13tURiA-_%2Wa=p6NC4kf$}s*XYNQu-_y1HMn=ES_vr=Xp4J}XUo#GdiyYK{!wv) zxRclNtahgFm@Am|Dw?iYisV5D%$*ghZ`1*}v#0C59G4KT%^HV`i89>)*Xac5O71em z0SI44EQ<0yxnK8t6cdu%7{luWhTT969xr|Q$r}TG? zyno$SlMAK`C5u9tNrbFfejL@cP+Ue+hmc~Mc%C7DIwJ|pSXq(1+ZklU+|R9U6EXRt zb#Kwl_3OW(BZS_zf@!oNpO}Z#HpkiVnS0E11)r^Wq+Tz}g5V0KIMXD zNeYLA**MFzKp0Ow_mB0rrK%3}2fgUbE0xTkWe?V(ORf_*h&#QV;Xzq8RNq$a?y(@c z_MVq-k=!xY@f%E`JJ>m*x6tGHu1%gU6#e)8clt<+?MH!jJj?J)ZT}+IJxcEXX3^_! zM?X9Uzw*tM`axfE+^J-UwHCL2oAZ1$+S4Fmct;V)+Nm>3CJTNj9?M53F#b1tXH3+O zZP$+LnAB1-zCrNx%$-!BdWdTHD_;BCzW4%O(tX#sVwYA-0fy=!6z)I=qOVfLBA}w@ z!~n2F_z_uZiPjlLm~(W{g*HW?du&QK^+pJjcT%4%s`%l&x-?jTW^+Y{Vj$X3Lds#F z^VrB1#(%dR{pi$W!Xhs5TAtKDp(9U;H1d8wDnt70PA%N!FL0j|gJuR0-S}&({67N4 zU+{h&%oyJo&2OqlN}+<#a;^}rJE`X$nm^^QH6Bk3$7nuEA=1W z5M$+Ofu>C-!Mr>ABt-A2oGWq0F@%?K(0k^}<3|a_OCDpKp|6q%Z}l9=U}f+dGUFa& z++plrYd0Ql@hzZWD8zEBXW}gJkca>8;nFPBmOul*c$KyYO*>8zv4Mo3GM$$k&CHaE zb200ycFGU5fIHSXHb+h)L+#NOwo#sKaAPr4h0?wpOx!`%WVsvm6^se zbI(OhAMOz~FulEYJWVY3b_sly>GN^hQ>~RL(Db%Br+BoK(7&W}LT9c~Y62Xd{>9(D zkwQqg9jZ7NlX*{#xv8{ZY%k6XS&*G=q5}33@N3YM;imrT3EAyns!8ZY@pBR1z=430 zyy2K~R|Vd+l%-GhaV@$Q9s6RFx2**R9NHIgYYRp%VMee})c3`Eo5Cu3JVpj^mWeEb zbJb(Tms0h$GbL&NsJ)M5%Xi~dUI^4bZVq}dxc2|2f77G-8i#v4su-c%4tNfOR7t;coa~?k1uIQUoQBwkG8#2r`NzM;X z9h$VQ+G{`D&?wB{*IoDVBlZTuK=o2ERsZG4Z|VJ*vYZ1r?Tse<+CANo&vABB;N)t$ z+n$j6PyZ~BCFh}!ua#+m-?S5}-@VE5j?|jY*tz_F;if}pNsNbl7d5dAt3{wA$4j<( z7HH{_Qpx^=NBxSoQ3urDU&;n^d-(FRyZT6#67C1s8H6 zt38d!=0;btET84HE0f%6Q*<8r?iBLf{ptXm=U_g>BpKa^U#9ojdx$AazGB!dDOT}_4VKj z4~LimdO%BW75Q%zL=0DJD1m$L;<2C1*MMal{9v0nAz-XtQ=a3>CNDQ@r8}Gy*hw`oKXP#K6UR8^_ zKM*#AF7BhHQ-8jaA+G3Arv5VAIH+;=X621$TA8d3H4G7<$)_aT{o;oq*wu5#sll^J z>U;i{JNYxbar@8GE|}{^I4@vBR;B-%XbUrkq6Kc5IkG^6QTA3mLKp&{w6OE!c|_`Y zP}+RTJ|b&3;P_Z=jH}}zYJzs4QR-ny+(cx>z7IX;7+UU8`rMmo)gaCUSkSO(TVpNHy*or z1mP|t7H=rSv#y|8w+O7`gmuzfLUVUarG-|iY?Ao+_%&;E93MHylkP5yB+ri)*uCd@ zMh_fW6lY^N&yZ{Usb4y+`ZI=Ols;z%gv2YGBY>(mze`qM#BB4>zrF>;f6E76aiI8- zB--jJAmE6^&!&*UV{fgWuZH_n>vo+1&)0V(CM?&-6DT2i1o`($_ER$P(zIz3@AH7+ zF$=y{vgv&9v0m>46(8{l%+HcLQ)CfG{GNADVMQ%G+Y{Qfz@`#C3|Y%Cjny8@YbU$f ze5)?;tJU&twfjjWA1S~et8?h$3Fb%46C$VjybwEut(LnJ;=*0!28BKIPyW@Tn;*Qn z`qc8^17)*8;BfPB#I$vLV{c;8@IUUb+S>$T1@fPJ@7`3Q>eg~c1^*Azr9x7QM?pCP|ekOd7Efwz&PT{ z*Jx`S%5+j=G^(iHvhp{*|D>WguLAJUwOU_I3h*a*Q$9J-`?^6r<>gMAPnJ*|XC-Q} zG+VdeX1#UG>;^Px;B~c&{{>IJLnJxNkObcWkMtBn2#KXDvbINDK?9>`l=WFaO3qc} z;({1v!rN1GE=XIN*-(4Usn_9McJ)wgR#2LCUx(JTC9!S5JN1}hEhmm(V{j}k+^Gh4 z*^;BhsR4(N={CBNT<6ahB1izDxDh}MLRi;m-Q!X&Z&8m$m3b{+YA%lK(qve**j^83 z&oS(OneQLkjVc`W8MajHvZYSY2Hj!M`y+Yf0KDOje$Na^lt<$D4bMuPJmo?uR7b z(IZ+r*Z-PWA>zkmu32LV)k8IwZ7@HIyMzLa{Dg!mU2(Aky{`gx0^+2 z*ejs3e6u*1^^p)miOwa^Y#?ATc`oc}Qf0)Ufea?46Gb~aXZ$`4vmbeWTgXB1ulv5Y zotOg|U%wxN(fcs@x>n)LM{49`<7E99hMS0NNxr3(l1pU?_1BjDz!;R$4v`9?0~p`Z zYAdjWVjQN1*invcoTJV6`nQ5fL+P6qxd0R;*{~vCvIIgB;=BqjIXz+-<7lP!?sV+E z_-wXoK@OjJI1GQ$X3UPlP+zVU&LUhSOsuJ7y{(1!EPMA6UokHmeRYyrR6&y% zv*uhb z49t@Mo$z12C64>}_yY{;W@^rD6zQ;Q5sB||GnQ8jq3v2hD?MoMP^iN$q;%MnUC}b4 zz)Q4=yFI9WmnXUcGWoPl^NdF4U90@TAds5rUFvKV;KQw z2Ak@b4+IBh)IHEU@=f&U^8wI53aoDA6XCj|*M3_}o2>4}rCP_YntxNS+!Cm|Pnni9 zM@34S#vFBL!1z(eJx)fQ)E^L{wOVxB($`Inf4vCU0_F95dSpD&q4V&>f~||D1bU0i zw+dtoMb!HLZuL(vrWIpbF z+l;EYpQ+~Ki7KscdcdNO(K&In5on_E{&U56gLhb!BV~SpvV2H|BF6GVI{DeqK3i`z zz2|LmX2WAf+599}hg$4x&LUcX0s1v7x_0bELv6^fX^t0Tg2V^#1Kg`HoC0Fj7EcZwqB*&V4F-EUyU=yo#-EfkDDbS>SVpo>It7*Qp&`I0x zL6_KLlr|mf_+vr~<28}uy3D-c8jXU)Z+P7=&6^~RmN&u$M7U+$Rp!4~3Rt>R*!Nq9 z=NPqNJp7uxhbw+tz=t^ekLj&a$@8D~lWt5v3}_7) zAx!uOQ$T+RAo1KnY+_h%ildDrqW5{Fuquq_JC#NfuJ6DZfECnJP?PN!)FIit2OTTT z4%#!W|9bGns_X!-o>G(!tAnlw7xXGfbxGZ|>Bf3}5>!@iRd!!9ybmX_;rFm2*&}K@ z12}Kdnppq=a3#}A{^2vz+JB}SsSy+541ceuF726ZO=rs+#m)>Fsqa4&;^#Tq4`sd( zN2GpV=%`)NtlVjft0Qoz1;L3T^%>iL5}b_xfMD*@b30;RhDjC!72)g?J{+)=lbgLh zbhM9~@dXh~ZuMheQ$hKsF~43Yc$NRCp-sOqZqw^EZSM?OHCD^O$40wBOD+k(Ld@G> z9?2^TskGZj)no4Vh0xK(F&Q*fvqwuqpqQ%R#%lAqnlw5o>=!7iQok(0f9CHG5$ED< zo<}R$8oDVmHxLu=6jil8`>c5N>>@lJ?6f`_t!dD?BK68VY!`o#*6cj(H=Za8)%lv? zKf^kA@Tsl3)K`IlZ5#az)pSq5`PsluzMU>RF;Ngbcb#~iQ@cR=WY@TmAi2J6>_ABD zrZJl(9;0xS@x4T<$|YW(^Y(yG|JSsn#vo_sKnf#~;=cYVCfXGK-jaStES$8Y2h&(Q%RHl(DLhr45M~0W14;-h;mQBJ~DqahnJYy)qqHYz_AP@i({C?R_`SNud ztm*8Ks(V@tpomj)16V6)0i~kvvy5Z6RA|!rUs#uX2o;&5;RH6BLP@mC3wI9KLdaW% z&}E`^^sOs9#Cvuh40bRTT5mL29uzJ-&0;^ErG-jext(%fp$bWqAptnC3W7KT;6oZr z@%weoXba8OC>Kz&GPn7ti6aR$%nB^JgvNdTIJ|dU4giG#c^%Qo(hry)N2M^`$WM0e z5Q&|2r&Z$u*+~_1^#>+fcH-Pr{!qCp)d@~=^}O;qaJBrG;wkqpW`2NgvuL*0)KxQ7 z{731{S~i)1k&s|pfmc4?t{!ZN+rIlErDJ2OxiV&qkb*4{%829^k zy2C8aH6WlDmSPDjNs{JA6c^HkZH3h2$wif~yROoQvz|u3tL-!Cg|mZE^{EKy2ya>5 zV}>pr!i&n42L19H6wAw+%}8n2g?AB_@A+djuH|2Sq$n{juM?OKWWE-NRat?&`iIsp zil80bT^Xy!vtapOiDuN1#JdRKMFOp*XhUBy!6P_>^DI)%+_zll`4VN>TzU?-d z@B5{CiEXuPd!pmP41U>`BZ2~$m_aPGMYegf0&na`y**Nk;&Ba#-51b~rX@|azi{x{ zKSE~+wO?9cM_+kZ$@+a=jEWMbotul~rULo1i@?g8$1VpyC~wF6iQiNYnXXm}rE}Y6 zIwEic3f|;+O8STQ8+A`H&Ev6%n(y-&g*=W%`&^Kf&5V96VymVnkfKf7A$W0!2Q9%| z-OuFnhF87Ollza&XY?LE=6>=^@f-Yo{i4_8s!spjA%Vm!F_`LC05jkB+5fhX0Nqmn zvi5wE3P_|cNU_FQ9JHJ`J}nbTpSMmR1107CSELq@{YAiF@U`$C<70{KhG>c#Dyh{i zJVS35JO*q6Z*u(pOz&d;FCqVS7M@tqpPjfk&NAdgtB|yKb5~_1f|~6Gk3+l5W}g9G z8pZG&UwF>afUaBYIW6sgi{GG;(>2}H2{f2=wG8JW@402sMh1@292c)6&uXbXT%*^0 zhsZuPbG`wHU3$zCEYIu6m3*B1u!V;K2F}YNkxHs`YWN zBZ-ZANDW?hx!@8^n)!#|D0U>S3N<<}IDt-HDJ=`k^wg0S&#YOPtTNmbO1g0Q-AI5k zQ0zXwg{ULCIxYKd5|XaO&)R2GpAccHx?t*7H~TRqy>7zOm@Dy7BBF9}4NYKQ#<>+9 zAMgpw0XN=`WzA;nFC|zy29kXWSIyUeugi)r1DO@Lz0~u`gaem205-W95l_9yUw?ns z@E*?9&5fz-`P~+o#0n~ameDy8e+T9qQ(XcbB*_z6El#Ql6B8=7rTpn3=pY{>x+mGc z|Cp)N-Y`RpE3@i}t6pB7eA&?ptmMBG9nqg-AKXtA~rA7p!b z%FHL{F3WgI&7|jM%Xmu8rRE;VY~E|5lVO_~|K_DM{mDrWSw&H*5K*(*DX|9*m%gER zWS6=3HhH(Kw*TagZWWx{KV}1>8YoLWbYJkdzHXYCo6$EvH>E!_qbmR-K$#xR&kvT- zV6yY##xDQ!d&LAiBsCtX=;y&Ux{#VQKPx@R6C$xogsWIkm@&(}MJ1+O$N1dS6LV0PEnc+t~d`%06{; z=acePHQmjTybgt9H7pYv#)D^7rWV!8E~Cz-LmgZGx6zT?vm(@A=BLB(%SaYoUEh}+ zusfA>#S!Ml-66mKc!N_Riz?osy-D@$bxAp0Xg%qE4guEj67{_pKuwNK;wh%*ky*#x z3c0F!<1LnTL#3?Wbt_o=mpaCn%S&_3bXH9UINA!9|H&~AnSc(8kL=ux)F8Iso za?00ft38F&ifz+-!*1Vl9S*>?1Uv5kWL&e~xdoSf~5&s*I|20g9M?o2D0-WOEGp$f&(Wp+6=^Q(~I=tp8e(;_2yw$Wc3i_J#QAvhl|E z=myHQ=PMOa1Dds8hL8#sv9354Ky8Ey+JnuLzBca7a3hU7z0;DogmL3!7ak=rk{dJl z6~QOQ&Uh~2>}NVH0$`Ra%Psr3&fW3ERSV-^N)&5`G?qLqoOAjO-yUA>VZNKNni*$i zpVXMZwNMP-_R&HBB>IvQf=D{1sP$_zBcL5{i8}(_dU$J2XKJF`dBp1oS|Q;=7l+r( zwZD9eyWU#&C}NGRThHnQ(PVjEJ8LCxknj`~RlTI|KD+XXR+9$_<;MFX6xA5t2R820lua=(=85rd3Z@g5rgy?zUzdI2EHjfaqCv7(=w`XWo1k*0HbE@$QEmFQmIu!5cwve8 z(5Y9Pulg;gHrhxv$(YMvR5DDIMP!$-8YCACYq-fJ0=RkyEDtcSq zVuc{v>qW=o26M$8c+ZFw)SbDY+CDVASYjD#mPdrTpa%3 zIf%n)w_~UxoK$xA!q`kf2!BT+jBMq$U;>F@ZAPKpi8% z`;X||3KssUAYVrTE)P?HbW%A|a5crftaUiM+4h;Szc|)pUYflRnh#|zjm|Q|I&C{f zAC-yn0`hQp%=ukhB=dy_6){NMXI0z=m<9ag11LmhqRM9R{ehNcblh?keX9;1?$zH& z{UT97%XOPdSh5ZTNsj}Ff#MRj%z2ca(wr8E5V9#7f#Z%3B~SzGRji8Qe!Bn*ff&0k zgV(ecgb%PFb}j~ZoSmb02$J_ zg_c&URsjCBfr0QG%Vu z#Rc+K!6ts`Rv&>I7;o_hzT)kyl$CRH?Rtu};O|4!>2FGCCbm@&%iBxfGI!6T!r~$l zE2Eu=Uo)03!?0mL>m>g@YW0+zi5vggZWxm6cX!FiVZCASO4lb+WE)La7{j{|xO(m( zsH(<6wsg_YFp{9z%!(hBDpfa7*3v!i;gk@E@jmk-wIMK8c36$+xs}HCDM})W^XbC| zyrzcMyR4dcZLj>Qhf=MSx6d3NI3pWn9wvarRK!^bLxx=f4-6k#O$mVK>VUyzb0E zQ6O&h$Qa^07&&@^)*v}4Tp3F|L~DZfW;y=(3gM@lD=6>BQ9Y5I-xlbW@O&Rko%ieT=EcZZ+k&pN;DrQsTyM?)8vA@cno9 zZjYKTV>C9xasETki@)q(ig@hpHB!z0w&?mb$To>=KYkNAs13OdV5Y;&3eTN#}wq3f>C_U@Yqa&L!$@|TW zW>_zxzT>_&%a5h2tcc%St8FBWr`NznvG3CCF&$QWftbEkO(}3eYM1^HUN(_k6YS%^D&)Uj9uH22;swRbRJ66MAf*Dp) zhz@nX^5lQK`gvZ_eVtUEIwMf5HXMFA@I50H7jOA+p;8wo*-H%2CTGM5n+6*(Cc%7+ z+Q(FAP4*2w2B}9k4@%6lt%9g_}Na@+IF&+5$>mO-@jO`51D|pQJ5=- zU4OrvIUc_=#%!D7nDrg!;03lzIyY8Tya`*B9q*GDzNiq(r0*!Pph|qmE)YIUa zW_SW00?*N|-K{MmTBcUZKPkCRW0$;PCFWk2(V6~P&m^juj>F*bE^BhG z?k7B)zr7iZCcHZ;-w?SZyAtHNe7&gTuFVH80Y0__g_>=N%Lh6c>A&1K9pU8Q<}dn$ z=9SGyk)&TA%Q*+m*!QCaAN>RXe;q)F{&KHh!Z^|s*@7eDsM(C_;kcy2Og zCu*S4(g5^I6vVy{&70w31V)c^k;kMCpXUvFzr&-C2jvzY;>C84El}nL@IjqT5w`pj zE#(^q*o`(+#<4l9VkJbpr09%!BJM-UBMh0MoJOxJ$P$qG8cd2MHtF<-;K!>dBXrE{_xlT7FTsj1eYd+k7Smx;fJ|83jkt*fHI1wyk^p&LQe$7wf1OgC%Pb^=(o zO{!E7@`x$-=5+V~kE_FkZSu57Mf`3U`+=j3IFmbUI|2J^^wu7>&I-rO%8ms7dUYZ9 z;XMcLfmZaPDZ%Jm7;;VlaqAR{%*cwfz4=!7Fn z*Iwvo+WAi{D3Yvi{$O$XOxqPn@=rK~z^GtK4k<-VQ<^(zbq9MZa{H5C`p#bY)vWX@T=1b*oExGO7nOfP- ziSD4~^$PsEebp=H)2^0Lu_>6 z=sJ^HTO{GrHvPZ&HAtN5-e{`H0zuYl#Tij}o@WSFjJQu>qrZQja05!-F2+LPDMb^= zzee~AA?O|T#$|&N0E@(9f5w0ie4P1^nmeBMedozMC6~x$ z!=xV=Mgo`SD$<=Ydwq}6D;@uDXQt__tq$cgRcH6nUF?Y-e z7B`3x`VSr#|ADcL#t1MV-vyjwHo039K%hDhaj}i!UpwOj5P$h8 z0L2EmcZ%z0B0LABua4QNo=DK=0s#}aqB!sV$ zZwNx`^79V$SYJD(bTSKnE75D4J1Xu_)%;q+_RLc`hcr_hF_;P(U~yK&t_Dt-Zge`f z0i(H^E*bRl+hsRp0}0s_Kne)e1SbK^$V!@AgzbTwJkKIYPCrHYojAp(7Gyv+qJ{gL zmLIu0VwfEAB1rqaFFVNkE%oa9cX?P`;5jpyldn6 zMKz@Q{t4>ub7=ij&BbZL&E22vG5?*Z`t9NAN3WqmZf9qdZ49LQk9-l#c3n@3!uj2s zMJGs>oBt(DTx}`0N4F(y+*vMurhn-z6K%B_Zn1G?$g{5b>Ok9`>DW`p4Fvoh>;|u! zW(k;uasB${L69qEJ1H>!#=kOa+ev0mA$Dfm_GC}$|A8&H#+Rr(PHQ~t3~xZu^TNB= zvH>SN_rhaE_gqTDZyi{)UF$8#HAdDw6Myv5odfM_0U#*(F4~QMlvu=463Ab^qYSaJ zBSDwQ)PT}DuB=`uCC6P>#6sVZK%^p=uX8Ao))(1KkmrOahXOaWR^>v#=K%?dHH?z^ zq!v$d6rTJ#;~r49ZkEy?9vcFL zz*?q`l{Z~51rWbLG-uHLnRb?I14`$Y%IXAqVXZ?1sVe@h-LY(PT_|TS+yRSDgzkVT zfM)*3vB(|#4skjW0se`A{7zuSpUDc+t0Z0`GjuRTsfP{S#gAbSLtHuQbw*^s0&%kb zQS(c@mXB|s)zf0vy$V4@-jZ;>9_$2OM!!xcj^><{>w-;!xetZ2A7Pbj%Kh-78X;t9McAHvGw>HoEyviUOL_z|jSZ@C$ndgmJ ztSDA(%VYIhM>R-3mzCW;3zGIEP}qI0GMt)=j*B3~yATn^`Xkc#i55i1y}pauOETI| z;UN8Ij=lA#7K>-~Zk}i`=lEVj{`}d31YN+Pmp`5ylh<~S+c?)l?Wk)gQy@P(7vm)BHhOyqv=Hsg>$YkpU)t`bGt`LgAxy;TLZHArXN3BUsO!LVgqg zloln*gMp3C@o<{m9;1aRjXWU&S1(+PRK5Y<@UiJjreMh(@yQ1a6sY*s*eKoyAgnVT z@15U^oDnjGDL=Yi3`qGsIsxsf;`lFLz+>CC12(1Rqw3CYN~Ac&)oqG;rdXh!|1s`I zh9NQLg_b!nC32xGmy17Lz9qN}eTmJsr;E7cm-y`zrFgAYIEkkZtuz-hab?IkQx36E zKE0Q8cvy}r3^WthDN6iVX|E$rSvsCO6~y+siL`l=*BDQOaJ#Y?`++Aiz8Nf4BWND~ zRvkMdAcj=|G!Ik|zl^jBJ*rM@VOWJzdVT;-P-p#WIN{_-nqZ%Zza|S(4)$xc56=Sr za~j6Hqvpw@`|*CLBz=K&^0Zxd<`~SY*&gLK8RWNj0jio?5GGVOF@@u4B-Cd)Gc*2> z-uru6abZ%Vx{-D=(CHntU;gn;ZTdjV0`J@JGRF;`nlF%3#}ylf3%`(T)+Re4Ru*rV zO2X^Q%$k@2GLK-Zx^E2MNJqVsrkT_@#0D$}g>H;2Y@r8oZk=I9{EYI%Yg+n{O9RUS zb`j&Qh4yD$rYEMdVIrp-f)NS5#j2@S5&?WV3g>ffm(hm72p8*bs6-U=jL4=kb+qhx1GIDthW__Rptzpwox zm8;gEKV9A=*1aJP%+7abH@TVwVpbA3F(2MRBZTZj%n*q#G>oq+Kr`deju-fAGS0+3 z)H`D8k2oHHI}w$ZmjmFaS;nT-j`yL9HF=7v&~?z_Dg+`PLjo`Xo{A#CBg^$iQKwV% z`~||Kug{E!Ye=?Kp|vB zAC@U_?t7NiC}1+bvA(w(Inn>TN%Qs0Vd}#i_-gRF#)+WA+d!Zi)ohDhiC@tFc?6dD zJRiF=no-)*eB?V;%n-48r{*HGbNxotNEV{?T0`P^K3>rUN~DvO%7pNY_FR>2>)DJ} z2yHo(t8X0P#rKdOucv2~Mj~cH=fB8(EjG8Nv=uVN0GkbTArGLG=oy6DF++{-1?bcp z3R}43+qG|x3YeK33knBEEHFr@9nrL)3DGV3#DAGh54E%Kgk52%(19@I%0K%J<}z4d zRi~o*={Ld?7Thc z;H)w_T49;h0$Vj7puD?n_DM(Kp+azu{r7s|Kf=)e%%d-$e>k2(mP0Q?YCG;9NeTn5Ay%5k(}tjLbBbE~c~Ujd~-lZ=*slMpS5psyB@=^T6itQrEho1B6q8Eh|S4W+bqRK*)7)RemLre*#Spf0E zA%T$IapH&i!{n2>7<;Kl4XaW-7+DSwa-@PJN(Okzi1tN%+)sfmT+$)UvRokE9h(>9 ze5!U{N5NUhqDtzX4Tv#8BD6q=aOJrD&dl!P@L5uzpD&XPECi`fV~WTIYJvd+tgQ-a zj}vwZVD<{Hs_~3*|A_WlvvG-=9Gy=XBZ>Dhl5z8*1dCs&(+0NH{pHTaX%r^!-r{VImLtbB9j zLFIxt#v9k9K$Y7#l_@d8v^z|l<^eIde4cIBWE)6J(kMm2&g+L(y%Fs_-HeUR>q}IR?)0Z%yl>3cR$3;Pj z$#BNH_tFjEJvrxq2w+B?nNMOoU^Zp?q+@=~U+ozOw~vDuU=js@Lg@gM4Jdw(CIb8^ zm(HVx`E*tnTRnFoNjU+BDfP2!e5aH>1M@w-nlW9~M~m-<^K{885mOUNR@;#JXqsSn%^q*J{Xtj2eLCNf*o)x5#8D1v%b}l^&G}R`Mai92 zvQ-|wL`tjFnx}@tKS7`Ni$(}Oyv$M=h@Sa%lP_mS#pV89g2eEWi@mLtSK`KZ7fYNj zJ9n_xk#fAjsT<6lf;>x3GYKcG^-hNC>V%GJ8BGNbJ5CfL3KHE_CTB*tH8>Fgo0T6+y)=d(Y(#U0bPU2)Bin41%6vI-RCDbhK!V35zPvaJ z@CU5|C8Lz2-NK;t#!y9ItOu?%U1o2q2l2luC-Uhr$DJUU=GGO9{TTmBH!^Q;GjA&4 zN4JX2h2*1s@5&=4Mq=C`VAZIMupuE*E>*Ylc_5+RgoXhx7N^oz$&Mu`BI&+xN_N zFcuTyvhHUuC$ys#wCRryY%7(dcShB?%N`iNl$l+6-OnJa7nzwzU|yv_Cup|t?oajj z^~uGEhmf_1PIf#9;YkjwiurvQr@9fhy&C`Z_7epq(g|ElDOrMkN+uub;bv{pSkCW~ zywP~a2+;1FT&%0O-_Qx3T_1KzXA%O4z))Hw622YvSDCHnhVFFj0#);QowiUiyL$2Z zXb~G~d0`Q?;mS0`b3jF#^esE$j-Y~~#eo?U{hva;%B;}Mv~uSm$}S@=UM1I>$FKFD zi#ZE4aSwiZP?|nB;!h{B5(MLFqEZ>u^t(QAK2V*U%F{S#ay`gd`2NBomq_`Du$&g| ziF_mWhP`KY#xUXuAFjQ3_N0)ne97@jh|Lr(v_kdf2ucpB z$moC-r_%jvs`R}D$iXAjbG8RF1$*z%pVbAH3;#W6y7Sa$tA)wLqPQJfJK$Y0Z%A#! zgMLRDF&%bm-2B*<1;%q`H=*@c=iyaO9jJ!=d3o@|mrW}h=$FU|$)}kguSDsgan7k5 ztC!X+fjsMhlA_0Cgxmp9a!CFxtfqr>;FJ3Cf*kdul0;oV-fy2UEA^gWX zdA+@=rlZ$9-B2=Ob{2=ECEfK0z1fVdD~m$Qc;O_^eJ_J!57g5PZU9xJNKNca_VnDi zNoSM7PLQuN1mOjco}sBf0w!#X>o8a>K~x`AI;?-F4KYZC&j+uK|i~)@!v9!{8V7F{AOM88U-Z1UHb=F zgv35n>5>INq|ew&I44tCZe7uR!Zr8=KS{hLZjr$?Q}rABTrg1i!b{aMx@%?dZaAXJ zltCJ4$x3M~8QXy3{@X^l)}9Al_Wm-|Lp}ypYceJ(zJ@N=58Y_;j7kT~ux;v=DVO>* zv{CH5aW0?_PL}vIhA!d`bvBSYD?|+BOfq&ws@H|mIo(| z_$9dcJEPdMk(u)2gcUh=pPUJNVqlVbA2NlZ2W53>mB7iVI=V_y(99@V6q0Hw?&#jcu)LsZjOz^73&0a5?dxC zVW(Fxu_iG8WmMeqNGP8Kj`L89V6V2}NNO(@C9AH4d~PQGj6M1f5qgc(9XnU&@xoY1 zLu0NF87za~6epaEc6HnZ87)*-E)cg#hW<#p{>XJkw3)=mU-(Jh!DF{yC~RB4@wj+_ z53yV>z=nOLdv4SH zh^_nEFLxdj@fYxBPvbW>$C0%rBhB3?LxHu^^9YrCg9k3IC!i_TtnBRN?B5h-XU`#- zKz)SYoSHK~aMV;akdbqmygC;J@`7!msC!zNc_q3@}C7?JM9 zM_1AjNN}fNL=27g5M+(%6ge>)M5vZrLJr1p3KcKc3?)axOg)9)2&4bU(OI}P`Mz!Z zS~8rY)Vv_uxs0JCusgY&`x2&ZwfO5tY4-xv$ri&=##@;O`! z^ym^PlZZbeNy_#@Uh>+p(Ih4H^gNNS{k8sZ-u`(1i%7Wu`Kf1`B_qMKJEfPl?MIDXmKPRH zqG(D+A$@iS%mdlJ1(MKy9xo}~DAsNAUo4RyjSJ3{aLr1VMj7Vh%1@PEZJ8D_$x%%=F!0UULs3s(O=bCv;SCKs}mw zuazPBF?UV_jYkbb9`S)$ih0QEzx)ND^E!&aINxW}#Q*yg7n5tHO z;(sRC3Jo^rR;dUj1jeLGuw^dSp6?6O%?{YljuuloYv&nW%cfzL6FdfB@pl}w$IQ_SsUB6dIO%G4L=AcjSV;uSCYwXv7Uyp0|$ ze%M6b%2|H7h?*wEyMy&0B_Uun+@tyBIKONXc*$z%Q{H^xl^94sDCA|_VL zmrcYB5*~^2g4o#+MyRUH+aUg>0Yz8cDveOIzN3?&IPa(OzBT7b8G~bT5B>|?9#q}t zIAuugA7BdaP}%B8e=0+*bW6hoCVQNzz4R0}bw3+_RY|^(aMBTL_@~h}Zhd_MU%y*f~;Mad-b7}Q`Yo4x%c0mI@MNW(vv!Z#EdyKy%+j~u_0Nio@wvx8@TQQO7&lm;@ZaI6oFqS<1I|LHVf;8j*4&GD z=cKX3>WuXseLLCAP|lKqC$ps_Tb$dTzt@E|(E36u2XM@m_yPJdvu^nAUaSg$YaBoR zSFAq=@}JP>r-=*NQRb(Q0QS=sRllsfq5oC+;RIKu5xC(QyeR!$(}6F)$+WF%zl3-ZEYzRwCJwTD=Y z52B}XOdi>th9_WtIy@qvIC;^2J530)NF7Qa>f~|jEl2%S9^~J;x0s;5+~-?v zFdX0afJ#+xDWRm^mg;SIU|BR?=bxzQrAgNl#e@U(z!Hn)`K^r~={cNo3(-~TjU}HB z|C^$nPr2!`z|na~sPIljtWGiqzhh?k05BFZpzK=mjUqTQs}yI6d_Rce zPro4pW`eWo*{SF6WNN+oxrv@hT7tgQ1AJH6_Y!(F2oDjOxnS;0mW~&?pD+yTFYa@f znbc~025m&LtFt@eCuXCovtWVm%a}wpX(hqs*zB8}6HvxFoHnPGN&QXqI8y|9s|x<5 z!8B(4*A~nAO@wy0E&@S`4afp>YvJ;bwge=2mWS=kv~B+zp&7whG$?vv$gnEl3wY5R z;OVlXRKS`7{9`PxqE^YT-+mjD24~f`If8jZA-Qgu)yCkmfO=jwhr!_HI93(aD?2|j z#RSpLTSK-c=5H8zLt{Uok8hr?$W0kG+yD1MS#JwpcCQ>s#}WKt;cO24-%_WH7BjK$ z#~PoloZRWNhrE7R!c}H?*VTcI?J)|;X*AY*MdxVC(MC-U9#+!?Z_U~U^2j!_HA4#r zDvmTvJDFRlVvj!==C+Wl^Rz2J{v&eqY7rl~K2JCWk3pcz5*k?$HQS;NUyKWUkk_mw z!666|a_7!SBO}xmw6b@yg!jE_wFOaY5DgpKsSoS{5GQ0S_|WhzG3qf?z-pT^K<*^m#K_))ox?xeya_ ztvUAMiux=M3);t2myCwx>x^4^^QWn7l@-Rxq4`%a504i?{(qG!zmluIi6Gn=-Yl`Y zkhpglPL4xJ--Vawa{AiT?*%^FO-38Q9&A8cpY?6tURp8X5Md1S2V{fWwl=C=nPJy+X4uM~S5jRT64Jn?sx;~5%s2vK_F@e$0; zKMMy;K$LS$yn^*Xgzpm|2Y>5nE+ghU z17T&{Pk{t@FeHNp1p$mevU{%**7a12wjM~uPnSMaVZ=W0o=3V1;TB9|bX)=`{+?q` zmr|q;gd0FaqMj$cST+YBG9_{Y-?1i^fb(6T{7Z0#6k--+Xts2-Wi_l<6Sz+9Cvu&t z*3sk4A5oM_>08P;bl81&%**WHZ2 zO@>@f9zx2T|M1sQPZdfgKw9B(gm8*YzxLxnJ$~UfM)mc^ZkuqnyC;_%hMK?g)fzSCMd{!(NeE0)lH zR#YHK2Ko-|$Vd|qm8IK{I$c@plsKn6`#``*XqcMa@wXmlJ)ZD}PcahUyv9(E4UWoe zKHCesO5b#4Cm6^;#ma|iXkw;BSRV3kD1E5~D=&MSm08LBpq27tBUQifV?XW&Wt*mR0n&S(= z^LFlukVht|{W}RaLK=znsZ~vfRA4Or=IoZySE)?WSkB!jhrSL63t2cRzPB40tZyS(EJwq@NF0l8rhEj1@mod8%eAe*`JGPZ#&YM3aVCn zwds=`Dz*lNBU!1gH#sxmIV{QZr;qo{QBSeI6X+8t3OB|GFStBT>PBp6IqYHwiqvmb zIrX)JpZDF;$06gE3$o&Iz$-P94fbp}t;B2Z7j>}2biWg|yVTN*YCrN2d{z}rN3m1D zwTz0>6D2|l70*Gym~y#+aTW$D#VD=y>aKl38Pf7fYY?#3^_xsoQ^4|qxal74fM3>C zJH99wh`M@71{i~+I;x(OPWZz9mi-%(JWCM(iK}6t;DkvRwIcK;2xWE;aAs&qX8Dx- z!V!lCSb(|wl0|Z28cTSC2qekZSV40W5hb5;b=W&GDvX5+}0F@ocK9M;uk;wzf+Venj)h%^d{-rfPJ_>^^Ke zQGq4mp}ESR@iv9O@pyn+2J*gdHNh+yuOuadsap+ zG$7F?(i`c1=o;jZQ^xO2O}si&ChpBeyed_hOuojv61=WS9!&38gk=JlY<|Z_^g@OqJ3dOy;vpa;PYm`3=P!|V?j^Y)M zAYp$wIWg5LRyT4}v@XS>aEQ}2v!UoU!^kZb{H2NZK`eLrpE2#Yj);X%8m+e_!*Wok z4P|m*<6dmU0m;~A8)5kG_g)GY(tBn6{ixYM#~g!W{fclY4>4UEYF=NbDk7zsjnj9P zf1bu)iA}$nHTZ?4n%(m&TFU5^dv*EZaeR=*S?}EFm(4Zr;B3<(M+Nk@T5q{_EeAJ8l*9@SC2xC1i+E^iDk7x-uM6$bi6Ew~RsRYD+ou*E zT)rUGSTid0blS^9D(LN=rO%5ke&X!TOnd@KcX*sLD&D$dHT($;aejqFVKRRw(2~X$ zBogXHc}4*k*wCf`o@Zehap&+d|4)l3xx;hUqnA=kYb zj0)~{!?g+$_TrH|-gwA%9?6{PWbg>+Si94c$Q$cDfx_p&<_%Xlq?m-G_iEU=k3~($ z_pHjFZRxtMCt;$wT~AQSeoU(i#V93jaGa3X4I$pU1fy+sO zA6%-{s+S$}TPzDv8#N_hLgO=d7TEoPEZW00-CMk43M@=W8H}F+T2eYos&XJg@e@*V zVY0!wpRt2iqe$jJGO2RV?_s$`FA~zl#J{)`#}vxIA}v@ zr;Glf#=qeRl8Y(twwRoeJO@=#Z85dvM1ln8?bC}NcnNVH zT#uPK%acp>+`Mk@&o*KIE!OO;>JbM0kMx#_y7`Xr>r-qj?P`0BJl{x)!@B3+yM^KK zjvE%iv3=#W>rrL9yZcvJfgL$5wCcT$rToE?Nm-3uratm_PJ!Jv-f-}@boQ}z@2el5 zHHw$Dpn22mQ*MgdaV$}i@q0eHA94it>y!sm5XTfzJ6r-ChOQ+rU9w`H_bPh&(XA19 zQewSB?9t|S5cVE)))t(Wq$2~UnFH{IX{E#EFM5C2wl7i7^6jl`M{0o)+mVTrd}&2J zXxs#;7@q}USsz5vwUPQCYq@^TkE+}Aul)H4d?c4mTdit-!&J|Vh4!DnFL6{ivnVee zW7<@;IUzUY87`MgKT#>R#}K)a=&M*=2~O!K#i0k;mdw21H1Qm%j-em&QwHY@)-U@P za?Tdk&~LDtDLKOx?=JGBR=6(KOj?cpZG=eN*mC3>{g{wI%OE#Pt)DIGmq9*Kpj} zYTdG~Dg8ui<;kBulwb`Oo#=N46Hq&14>?B*Dh;MMT;u-~{&^jY#JLRS+&M~3xTi9x zB!;53LY0jOdi_uN+by5f4kS;U2(!g}Tirm2kf`gnI7OCIJf*=?|HCo&U?p7G*{%Ki zwB#>cD*lya`Q8v=kkU{Keeu*+(u#u$es3}5S2zdE{3hdQUb4tH@%6n3-8liLtC#*5 zjST7+b_SO1u(SS0mpUtVQNz~42B1W2H=Z#WAPxyA2+K>I1Io$S^8g+KeZ>JEmNPET zgfaZHcliV~H?qSJ&hv^6gf3Y}DyW~nJuPjvwPjR575;MRy(|*KAiM9~v{yk2#c>&B z2_O9d3~`{kGc4JF-vjp1Kgm4kXJnol1|Wcx0x*Kq-?u>FUVt3Y2wYlrs2swLdEW$L z8HBi!!uwLOQGxd<4XL@=5pk$@ng9Vo<2wK#A*JEXL&@Vp;zDnAWK?i%s{*BBh__c) zPy*W*FU}(V=^S*74VjKbx6s><)=tn^NB50UZ_$ONXhE7|*_tsxR; zSCk7f5FmFNQ@2p`qo=N`6tvNa>m06qDUnP4+#I=OiYC9nC0Xy8b%9R+5?YM$S!T3;-HyM1fb43smn2|GV|TM) z3lge-RiyfG6R*%d2%}^8K{yO=XT=of2V&VNogDkGjQ~AZdmJw!IeogPv`IJ z@BWpkPW0^jYvbGU4DF)UdTQxaru40{SKA)j z*gjkJl=!|P#hcw4BhlM0A?!`4S{iEZx+GB{W~Y07>Y6?uofRc9;OF)8aa)a_v&o}_ z5KC@@x$i-+?aW^OEuFyZket%eBi!)$U+HJtheLBhh8yN-a*HDTwu#m_d9YuIng;im zPe$6!IeN+C%slz?2D}Z!6K$x9mla1{C3Ax~*QvNCr>_n-4y5>A*Lm(g6#dH7*Uarl z_siNYmi{|?H`bqxWv|OzhZj%C41oYfD0w1FZ|(`#K!A}YsCyG$aVeuMxST=ob|bpG zFlO>GeokvX4of4d$#cIzfsqo1q+^;LPat)nht_FINJLa0g`9D!b`0RxtI51m06k@7 znC$@pct8?0z;1!s-%=eXei>>=-}Lk7`aPh~c)!$uy@MEdVb=h%R6&dbTHubapoW|x zggL_O7YRwgQ=4Z4Rx9~i)+-o3>6WzDv%L5~h(@8Rub*Ow#&j)8LvQ*0I737fD4W-a zBF3~VvK(BZIfg^9DN-3oHg>F(ep>#;;#e<++&j6nEtE<8LHwSJL`S1ND)^Vp>dG-z zy{Df`Z|J;cQzS3Jvidr8GpMh$qn%JaQwPQ$O4znmkbjf|Cq!;T2u&1XY=3hdnJT3q zeytwO;FRgcZ-ml{@sCbPMAK^ySD*t!J-ZKy6`e3-~--!`+bhIOWuo7&i(+u)Ek_N!Q0s3kfLh(W)=&b!mCDb)xv2xF*-J@b66iR;8JXP#+#kI7^l6S)mQ>Q zq0RR_h_V0abP%cSOnRX;s?xe%>c8;QaHAC(`-iH9IJJ(Hiqh;%@XV=NxqY+9 zOA*5-8#gRbice*mb*Zq;qIKIei1RrwX@BwwUKT#x#aYKU zwd?JsISO3*rNn^m9KmDLEtqzk?=hV<-uF*Mh-z!6(VlD={_qr%tJhY80dgX_B=@moY>%y+1 zEGwKZm2NyrdB7X5yl8Ajxc9&8_mY`%#OL$8BmDhsfJ8;cz z1ea|F$2%866zSTJW;??(@xNeZGN2~Y7u$1ftRJ)W0H0WnGgQ2G#Y2t3uJr0CZUoMQ zml1|FF9cEeDR!x2N3=E09xn48!wGSe1*=Pr)X0-B z&b>CbQ1H0{$JCK; z*J~>>o@(&lvwcHT4EfNVDZ_Ci4jvIke3NBbQiK)E5zj{w|HZ3T{c&#K<&I$-S^reB zc4|hp5y3~iDQfJdyz=@F>s+FM&f?Er|6q|fZtVWnR8In}J-0*KujRuh&Z+&Oxy*5d7yygz~fm`|jV(lc5fBzNnzyNqW@w*h#O zC`g=1%Z7C0=Y63Z=DL6m@ev(*&xDck>u+zVojp`FG&ayDJ?6L0(+-7=q)K?KuJ>^n z<&^V&Y6onzx|LGr@LS2;|NOH+sPVxv1}h(EHx+@nR4s+-N~m~-&)@z42d_JE4Q`vx z$}D4s|5^xIachDJdA5>#^0n`e;;o=L2O96@;}I{IpA+Yl;u$eH5MfbUQ&3eAUL<8_aBf5HRe1a}WKK z9EV&Yc)DsaF4RmUGX85%Cis2^cNzV=s`KxASYHPBUG}3uf5!zfoSN z0r&v;%RktGY{JJkhg_3Zgt4p@n`#DV*dgGp=p3`i+ z3}MU%@o_jcww?S{6P9EF)o>!hPoEzdax6b4ilo?~WmEZn;xJoa6oAnQpa-C{)bTFcIcaH=DR+zj=j&1TKtt3~UBt9h*D$X>bfBh)tzY%&@*J;%!IS;kxOA?j`e%$gS$?HtHau>l2WkW4t~uinK_mrGOS>a}2r zG`j8hDI3-MSvzG4yd)5}1+G!$_rV5Q@{V4%!)|hqv?B?3Z+A^xc3N=DneVo1Bd5O8 zBX3@EM4X1sdP0Iv(tb8S+GYQxZ}v~_pJRC$-{bdN79U$p+RFzUi}{+1Fut@1oj-&M zN;hu#rq@KyrfsR_Pdsgn{%ECWvG(V#I51=u>qcsdD+DKiB0Hf_Gaq(hvKF%N=pPd7 zVU&2F|TW$k&N{b2SOaJLC39Irga3b+AIu}+pwK^#?T^Ixayn%QB;rTYn}`v zh~|MB%d4^dEgOHRx0pfJq}G~PB&hA8vT}@8zR!O?@%Wsr#Pc|!o-Y7*MCFt9#+d%a zy~Z^Uh`8#wJMBcio((xm`l9N4#(0*Zl=|+78iy{6>DzKhpcmFG&8UM~lLt1y#QP=) zPXFC+H(rogo|gNz|5*1x{ezp*x^&s{x=tyqzhhWTPU@KAl|k zcwXl%Z%z;e-`rB3CvK*?^jezlG+d0{=~rx)%dPu{kv`KvwMaUvyI9an`)-hEE4nH3 zoD>i1=lc7%IR}XEM?9mNJfua}O}|+nxQ1Ib+8O@x`^8Kr`_{ujV}cmy4VZiSMjwrP$P z;6a<2t&;ee*n6Q(nLKzO1zO0ZV`oa=_F!UXisP}5r-DT9Kd}E$9Tz0h3#ug>OF16} zp^k3fH{q$>1!pVDeSDhvILy$GbuPXMt%gy-IRYez03F?InM5bSas}vH7)R=Na$;s6 zFG>3{40ke@`;gg$0xgjsKyKnHzE|H?&0Rj9Mkf+PV;44HTt&>zPD({&=5^4XdwU}< z+dtZa*5@K&@5Q@HTO^i@X^?y+P7 zps5MQxK2oiaGGcZR;Kc7u_G;il`j(xud76XPQLSv;~a6GE;rb}M_;yBk)$54ES9wK zRwR^MFAj98o@`ie&rip{w|{Y(v&H`~=33$Ek7>b0P#3x*<$&ZG<`+K zOJqBNnvKimMrkkBSyhrOBvpoTP{sbt;J1vd33cY18l6eM`;v5OuP0C`R+t#|`Mv|` z_G0#()#+jHUEg-4%5R?~^5M9DNY<9lSW-YukWHh`PQH|zs4@f$)3}@Av}`9|u41Nq zHW%f!`d>_51J^_g_42eL;kSL|%5aLi{ZJUw8TdKf9oq?&0s;P|m-w9EpBuj82woE# z8&*bf1)AqU4E3`NuE)n?A2*bLCHmG3C){G$C=m-dJ>yY`#sg5;uTMN*3_a1zdzDF04e~vIU^1QaueXV}Y=pPbi^Z;^qx>PDf$$Z7e)6m%rn#`8h@sy3 zciZ`8z=h^QYy-glC_xO+e^!RvWs`~FDJLP%4T$@Xf^M5U|0u`RNKHIwtc?yZ+$J7k3 z6ylZ>yK#oNgpy-4Lr2}bhvp*TbvL}It5Cu~>c#%^~d zz-ixGr)eJwo+TbQs1?;=&yz9;fbYNysX3sr_>}_9z)lf2jV>4wKrBCA9mIxH@>PS! z&nu4}=URaQn!utY4m?`~q6y!~5)fPiKeAnNJ`-JxuA?-l{yFpVYOMFi%NYT*Y390@ zgT|^5!xJ>!&FekYqLuJ1ix&Fum$zelwedN!1d#nimMuMvWGHkcZw$vVt9bAt)x!}e z9u5`wAD!q@RAuf5v{#*;t6SMVOjI$eo#Zrh4ZRvpZMF+{jEMPUc&Sj^?HoC+RhBugokA5d?V*y2l+0Ow}Bjg zo9QzfS_!`$s#cCh#N4oH*e-d~7+}G)lIbd6eM1?1t%GS)8n#`aW_}0}DF$5D4FWuO zSC|?)Esm9IVnThWi&<}XB(ViS`;f0$#sEMd8o`{MIn(8Rm|i0ml=@>02H3K9c#T_x zF%f?N5dxH#2CRUPb$kG98@(9mGx)<0cRtsL(itv2xrjRa+hcvun}$^wk*QD&*=?L> ziL~(^g-kM?t1o_WS}=5n78#=*f_17^;S%>2_&~Mb@)3F)jg2Wg)Sj)|vHkfuQt4E= zQXUz*%8=pRuP>{;n{qCoLUj6#e>!H?bR9eG|FUf~FSOY|oOwnBYWSJj_tyY+{o?ca zP-ye9e6MamRgorRy>h{Kd~;^dVy4ZK;DtbE|hd;%$2P3hstKpDxWF)4Km? zi{6zotVtj^TY=%JLsi|1h9fL)pS7BvS=D={^*+rppS>%eTl}*6YEx&5YmqM+sr27* z!Ov5{ORIV<>?8yHSsNMB+(nZV`7E-dlvaNmyC2WVM(VuNqxL-czQ`9H%Q3{G8bQ!O<#P3TRZY=AW|=%N`6*PC@v zgbBaIV{T8m6}(f!Tvbc@Z#w6h{MigRR4+jjw}w$CgEe;>fcb5;<-1 zva&1o8(KEoo}v|);db6^a>QWk*2~%0HZ3sd9~os0tp^dYeLZb>mi+h+N3K%5WJ)2+ zgr-1{{erqK+0rkTVcdE-t)?pYG&J)zw`vk@QTt-f*^0@hWp$ZcL_HE~85FhI$zs-l z$qD!uwNjNwdDslA6=jC)HZ#`tc_fGfHPqwQp|Zz^trX)pJNkAY!*W`80;ycu*b;RD zDLiq978_U1H)@-k^_fr$*!@cOJVJWv$ZLbPSWQzoF+Kk2eUhiW_xJ;dt(~BK_uF35 zG>U0Kn&gW=;8Ow!oOtx~HUxjq^j`GoG*;!kVY`3;&YrUOZSQzY^E0kQ)#hb;c48~u9&WM zs$yR|^+?!F03m_XXp_B{oV97+^@q>7F5HF2vo6hQ<9eFuM+eU<7XQ6T%lE*Q7YWaO zZmIGxY|*L=42!(`vRJl7YHM@OZ^QhdbyAKMqktE@dfVQLC2g_>=WBwn%y9Nk@yk!T zJ7Rxx=^xO`Gf|OT2fmjmpMRXCP_HHrX;5uxxL9L+FS__X|FpGeH zaaf3kY>?W4E)wsl*6i}-nx1kvXSYWW^06VhL#8tU}}2FmCHJZ*QHrBOxf*X!51893EB|1>Gp-lcKm07kl_)E#J zEiElnm)jUsIGsowv{KFqHvRQ&%VOO9u9%B^xlqVmaDMa@^M}453(pk+hvt9zW9G!A1jh@IqEtgln5|1 zPjOt6C3%j1cs#!#eMy%TDp->zfpdS zH)1l*VlpPSJth>lhj_8ua^rDI1Cc-yN%2clRD5zOO{OfA{l-Y;w=bxGd7EOZ3z%OP@HvEj$1{TBR$bHX=ZDB&lcXY3_-M>%?o-5% z_4F}MtYrQBcI9ayRN$`sz@93MOYa-XUrg$uV=dcHx7^lC8d&y>|K_dnIXeAx!`l5| z{3Jy3kamVlt~oDT900IqT58HBh#8~B+~XF|=WlJvOFUA_8l5BCj4doybzZJgB`8}RkoQLWx4=xQ4jv!?V zU$v+o$@DvLKi3I|ZDO%^4O2qr+n*occT-aj+xGCT+NLZH2c!TbSY$`fA!qRa0I#<NCIq2V8wi#{ zoP3zhD0=rO$18YAE-YH%3*&Tq_h(2XBj!s z@nm46av5O+4AkqzA&u+&R1Urhvo`qnOnZ+@GuNGjbTYRAFR)=vZ=kkH1VF4LO!$`U z`Cn_b(p!D*ynwRuV79DZp_#4=@j-_WoC#g+^C(Bp4U2L;RzeNG4aM(`>9*Ezz0yfY zQA!-E%z@dQ@x{FN$Refr&H^;zu|Gi#eZ|x0S4mBCVeg=0rl<&#%kE*|XNoO)odOaG z204B}*+Ka^A@9lo1q3Cvq)bW_x)a;=ZG~lv<~Tj&G-a%vW;@*;i}QG?oeMJVSYbdj z*y@mU5P*KZf`Lb>Xo5j%cjnh3Q@il!fcf`htQdOBP-Ij%fAIL4w-!o-;Q6YGYvUdD zPO6Pc>x$x5Ae#1KkfLKlI8LOU=;_S%*i1NNf21ApV;5UIf9}E`h$MQo^AEC?53chJ zW4g!?r{X5hT@aGF(cSO;z#zWSCUBwuk}f|SdvFI2A6?(on)`{-0j4Zpp0tUL$Iu+h zjC_p{9Lw>r%fu4fK=O^nBpC#nO)I~zh3E$U@slg2$dCNIiA|iNK$YsbED-~xi82&sOjIle^SpxP2mclHMcSUQ<= ztjy~`ELf*GBJJ9r%}(RmBKN-#{FgdJKz`TOTCy%!T;W0?uAjoebq~N-zpzE%xSSdR zr8ZtdLgCRCxz!3F(8R0D3pnn6mXo?QmHda|#j0+xHqnc;^-FNqLxL}|XyUmx%<+K_ zHSNRbQQh;O32^G)FAv}(wJlo1IfZJNHB*Bgo0&2k)+0xfVZD&{QS+$7HGY!fCdYDN z|9xs+k_`^?8q^1T%Ml~}v)IbQa^L|o=QTvU{tB<2fd6U~zdrtft>X&q8DaHGiGz6u zt>_B<8=U+OK%ptSYyS;|-D>rFZgNE6sAkO=_wKRu9E~;EJfnVLQ<=5qx8ss`(6_3i zOc5t;;jwn>d=mA0PNV`zqUfLcwqDyL_n_Gd>5;uSw&t1m4L#cmR;Nf4y8^+d zoQ+nl7>516%CwJ6qd0R=Y1`+S@j=B0&t2EhSipx`;f z6mGk}l~24BkI3$+?d23kX>ddg!i&D6y=&V825~A*D8vIm= z#cY4C-Dh>P#>%Y{5^;)`ZGi#y8{jI)Jt0R{ynJJR8E_=ZXSSX)dq-=p*=JFvW)Um4 z^~*OEq4Il7!;;LuyLt?FKDRCnuSuqEG5G%=$oTz8r1&$`E*|^l7F&j-8_cBOFdPgm zpH|lPapRF&k+XqBY2@Bke(z7Vw{qBa6G1a!MW{_rEjJr_f|;|0#m+3?h)xhY@)D~& zNDKNqtW$*J{nTi~JRw z6{p+Yji&lXtl2BNG$5xRQUa(}m&c1(0GQ5stHgif;YI%}xqK2ZzD#cZLTz6tLooiV zZW4d;nXSuykx4s4je0iEL8D#3>+U#WB`rQ{92W7Cyn)J;Db_H0V1Bt$ys#4lX;V!lf54wE zwQ-fv=t<)$RGg}_HMV5_m-6f-wWi&*r=Cw22t{&Th5P3V$5p8_9J>H07!qE0Pn!SK zI>G+Zp617>>V#s^D=639X01bQD9rxAx1?4Ul=XvE5~wfv=&FR@n^sbgg^hV&?|8@k zR<%*ZZbj{{iI&aKZ+s|IQ_~`0O%}1%a3pH-@rZ1g{!!vCpAfQp)*#{5Dm7(w)Ejnu zi0gWHc4njeS=3p2eNISq*-odZ;+6}uB_V;d6yBmWWl9yBiEty&m{=Pb%P|XsNf@Et zI3L3?U#ZQ1L3l&qIEuj*vB_1H%EMQ z0I&1=%icw53!OfBYG_uq7;|&{`{l0bc89Rvc?^#uh`WE<^ooPQPSv)Lwtq4hT~I+l z(c0+}i5t+Z&{y!n*tOT~W>r@{Qd7E6v`tR`!RxkTpE@5I?m47>&q1QvxaPCr;3T=%z?#O|$A%G>Qy{e6}-<6h{ zM!s6mnSfefkw#G0E}_0`>Q+9*17CMj3fwXJSq6cy@em3s2D*O z_MF*Ul%gm4T7VL8XI zytqZI`XS=SnWAe>D@FKkf|{4LtgqZ~4&1*6#nz`1V&&i_Ok>Ub)x+#OrTG1g(UQ3f zg33TN-rspF0Xnfd;sO zcR=rY8AgO|6Env(Q%(_J$6@I(QzR$C6}o6&Za=6MIKnRi^Ux`Ni+Qp3g_? zgCCv8enB39ipg?QQ#P$tF`H-UvzdodmxudA0pC1e=1* z+HJ9HceN;_h{(p#@%0LS)`u9OWs`|J!XD{1{ZYPHm)ko5@ppsz?hLmCFVBmxr5glfUj|3hX|6nk@W9ywvMte`wSS7P%6FN5>sq3!j@rFneBA>sc>#=FVFcKkSj*Di8vvspG7~t_#-D=A@{1?K?wek1KsR+$Fnj!-HMpZwAGDZbEkR8ku{&9;r;88TYJUZW^{~O!T z;mg2?Wb{}jD6S>TO!+K(y!nwZGaz^36B9ei8)JEB07BN?f7G%_`$tre`+$#qjP_yG z!GQMz_Ws|gFpQ-s_3D*x!LR=TcNu{nSDwmlj*?t=H1~7rfGlEE3-G_Dx#_Z0-0#0E zLUrJne}~G8YY(#Nt<(RHqO*)@g6+fj#^~;l8VpI1E)g~wq(wxe8y;FZH@X`I6&M|g zfPjcnqf-aG_uz`+v%%cPpvIOW}R$;4aLAn6PSI@3N4{n{TfsFa(Y>@Wcs*TDuq1tjvZ% z54wk}dgBu(nJv8&qY%I8a0i_mHy4TE+7z+&ninG%kS`GIxuFt}i#_o>5>@HV4S1Nj zoRt2k6$JolXYjvHe6_|+fA=_2z9nZu<7=OqSegIKk;d)IvDYI@l&+tjtF4B0vk1= z_PKD5qRT1%r%_0oYTbyRwO4gM59oKhpw`EpaCfx(>z@^~HVXZVimnRDigSg2?9s`; zRti^*VMp>auda&6%9~f%qF$IJqCU8OywZ;LG%50Ri3=aJ7&loa`*w#5L0mYiUIymh z3GYgg-i_iLGQnU!J^tnFP%(T(#{YPG`~EUdPUN?YaH$;Op9aPs1(FaH{e&(>Dw8YG zuhcTDETn!%U+OzuE%gtlsvkr?QjJv4KIJ2{rrdGsFy(wg0LhfmzyMWG1?z&Ba++(d zprYT`RYs~X#0{U9d(RWUi+xFU16p>^Z3r=E1=4hvXuMi$0>p{RzM%$nhfL5c$A+(^ z>=3*6@RL4dn@`Iq`pZnCkO=H_n%HTlUrY9P?s_XQZ6bE)3xO>({+N?sxYlS!i=kU7 z^&L&7f@))+K2Ig(W2S`dV%z|&EFgoecH)FqHU5)=G}|#{`VdBlmN^f@l`=0yVS3LE zi>hpVq0v6vqfCFPW%5vMrH9U$3Rg`^Ks%>F0rK8weQg3=B5_=fFq9}j@;S-kH)6>C z{Nwdw++xR)B7@2~E*S@mkxRao5g&z)O((KBNyuQ5JYYWY{lgW%h0Y%Z5Rj9I0C2XL zHa8OAu&!8_IejVV^~wRi4LA0)f%7mev{Cxpis$sS z5KA?;p3m{A!R!-Xov8DV8N0HUmAy{Qh<^n#wYwa3LGM(0YVRm9a@>tV?)ytZC|`=H zLT5dGwJ&uUCV;nd`1}(Y=KG!iBd){ko@E`9kujPRnN}dP0rdosA_S1X4w@^nXX*<2 z<88lmBO|Mc=XGK>J_v=}_I8z<#Kg=}lamP+Cc!ePt zQos@|qOFvJc4`d{?;zQCaH@-<6%{k|Lvex91B?E`z?ROfPYB@_Hfizwhk*1I;%{#J zjTfaySkhe(JO+bW(OJiiTNNp20#>yv)9xvD?ci~@*U-YJHzNG<@Yp?f@YQ>!)%_XA zkM45uIqO~am~m)=AerudN);{*$!^iWHz+gGO-C@RlK#T!QZOMT<)cj>dMguQ&LNZ;(e zXZaFD_CYV(c@xgQkj2&{rHno?UFZ0r;FBkPE>MvCEeqjr(b1hZL?v3W1sbX51{@QX zze9HR;KcdY4f?(wD^xuH4}wgdRWPB(rJdI~fKb)H7}A*FK)=uMlSIjc)s~)Pt^o0e zn6MivzS}^3@)5mdmr(}jB7;4<8a_YZxgCqF*(@seBKSmF6U!F@_I+nq%jh2>ko4Ya zw;jKiU^v4UnPz)+qZzD8Q5Ve$`pG0g0uuv)hF7hZ@p>L6c(pzCMDSIR5%zz#amHmi zXD<=)`P6d5Y;SLUJb@YU1L=6oir6U=-3uSQqYVH82*hDZ^Po_T4uX@4-&t>4la~kH zNEfvpziI6E7q79>1lXTIBS3<}Yun7!&J>>3xA13`S-w^yeppMrJ)FISc-1J2%$5*~p+cp0mYn)aZkK!zD zpGimB*Xbi`-_4KA7T$!AVXN-=`pLc%`q-R;p5uBG(bjt&5r%W@L-N`{7MwuuIzG)2 ztKX;J^2I`N^H&kMam3iNM`YHKS^$1(Y9MZL{gG0um)J8<4i{e*E9gy)RkimXp&goc z#M2Nn^8_w$e?JS*38lM&fI`Q`8mR@sLcou0*8+E} zyq@0c%f7_2ruJh9`gh@7H&gIc^E)ZFJsiVxPk&(UhRnPrJi5-$~v@EIE$ zm_sXwU!kLuRbX2*;41~xS2U<{@a0?{$=sV^Ie^!+3TqP{DM7=JBjkAeDvmhmg`vVS z?5gBl3#FkMhO%&{c>`Lgh}N&v$CBON*5+n&Z66@hf1EnMtCY_fD>EBuA{g@rTwQBo4A*aaye1GR9U*0MGr;48CZ@klKyRhm>QE(^b6$>2S)4D1&NS%J7SB*v=3YroYvJsF zOEPS^CO0%&(_zs*|1EbnCd!DmtyAcH4rTf~L}{&jLzAr6nru@_7@sAK3Mc0_hB=Fr z^RB322NYsA>y?h@yRtzae49oc(ON7fA9~cHF;YN)H?}nHrQ!Pfcy+8rC4V#i&3lDa z>-qEl*6L>qOIMX&1lOomeSA)7g|HnH&koF~#|`)=HqYt)MyT=oSPc{d-&lNNX-n%O zqH{9d%8#si=vktF${~xhx!#|f_3b=gBoVjZGFDJ9wN{fqgguO#)~FaNNRIxHdXQ zMsyfEbNz(AMMV8~1MR~CZG+c=EFeJHvbN#tKmoct37B=B6Abm8bQ5D-No<}fh&A3} z2xG)#?^xAOh>~NkTg1aA{R(Fbh)2&I~#z%GH<-6|5kKp+c z2*b@<8~a|DYYr8n#hIlbKC$9cdR%L(EB-Vq_cfNL-0u!RGG@$wX%S(+_E0nM zF|7WWn=Mv@H+Yt@bO@M!vKs@|(8|!u(>Q^W{ z{P}%*r?uz@p3zM^K-n!Q;dZM=A@U5cKj|5z`0v5a-kOiG+T@NUN+)=^pD zhh5X|bmw#`y0Ps0dJJ&;7bMyp# zXy)W9rw0oQdGdGySlc4<+NzQmWIt9wQFE_I;pcD^n%fP$)RE$&ZMfyVD)D zGS7PGy!CTqwwEK{5-Gb-7$cZ*aWCRk6Rx%3y6KnKj{WEhQ6u6}e^D-9Jho+s;TQ?I zzR$JGs7>Q8e_75QKIiA^&eS{hD#@@{g0pEZd0N- z%LwODxk{l{3WfsQg=aaWdT&qsYS%PY{OC<~pe%r2;8paqqlW`y&XU9}AMbXB=nd1Q z<%kIRIQr{08ocUs(_q9^#HtAJq#1(c#uNqc##lmPp)`S`(^+cJyZni)2teN^nFo>v z!}#)4;&ldcqOU-g{*kJguh0kF9()!HhKBf6zc{eTfDwk{ZWGVNFK6iP=pN>t{HlD^ zO6Yy{hCXXK4~DqnE~!>EU3qvEr`V9=5xxanQEqs)Jw(VN+ z%~!F8?H=Ojf5Xl@6^bDF###6G`oooI(FYi+h1mrCxbH=4%*3XX=!bngL1O&Y(V@zr zS<#=TI0|yfC(G{UPZF=j_l}%J{=~;`)8febojq8R2l`xEpvUU<&JS_@C+!R#YJ+Cx zhZwR9t*Ts>W63tU>Xq~x_E)pze)%89=F;Czl6zXqk%n%+IYg>rIv)}OUf~6J37if} zgImd$=hOzszNi{wyPXR#OxaYy>}4P;mHG5p!>8vI^l71FgA<+*IgCXQUu#lNX#mZ~ zYsxO8Wi{vB7UEy`)SdI3IDHMm--6?<1R)E(mizfz&pr9M16mg-N${DMf!u1+VT&w= zl_w;156e+6pU@@N#w`A>vp3D8hf#;qK)`Rs(q-W^EaT*qJ*{~A9Q>3}dNZJYkHfG- zhkH{fN9k~#pbSKURD|ZWkT0}~cA%h>=;N0G*+gY*ReDdt(j`BXxJ>i1dn>;Coe_nZ zrODiqg7sMwO5GBWGPxGGPGx|^5H2Xd5trjwmOQLg(bIdT%CxK<+^RGdaXQjb`1H=R zatzJ1+gDrYcEi~8YX!D)Lm@s2Fs;C(j*v9`^6H8eX>~}`wX+=g#xs)@y1dkJBd5gL z6L#wP{w(Lp^cb8S1eN zDlaY5hPrU|;WoC-W$jl*FqlJ(kRdL6g7)fpHeVgdjUsT(L28!4{75}5?ebR&8rp^es+z1X+-_Yt9qqx_JT#0~iKtY@ggP@Fc_VxP^7ExD)W;=>=2?noDC&}o^P~Z zo2<+heuQ>{E2vySJiH3Ard^7W{Zp1LNqB5#@t%Qs;QvEzRJ>^%fThX+L@V&|>M}qL zH#z+TsYd+hO-b$CgLF2VVAX7LgHpL2ZdYv=C zzvn?M5OrsgfMOLuB&dufvVg6{vwsg7h^rW|jK596sPe!_0HRtEnoJ`WpcrAqFFjfS z6@3enU@vi@vNeh&()NmV1#l6!mDg@n|EEZbr|Z8gn=eF(%7_o75y0QVlM24soj`{h zh$t*m=I2Y|kBl+v{1NLsA&B6)rzv$Av-;HpHS#NnrnQjDD=f)w2|VI-$I2#TL&Tt9 zgB!m10p8hkxneMjCR!-3l>^M(OHF;TQu%X9aeiRc9fUV{PmKTl(pdyK{x9@r z9~xKQu#}S(UftF0-?KCG1!B9>mt*G_gS)n2^;~<26&YUDb#1j4G<(cK&>SP6h4zSN@*skvR3+lh|U*y%-)V zP{RWYipdKzdFw7|YlwlXY^CcO`%(rqk9FjSczS=b_A| z55lUs^J~9Z_!GF^JENK}vYbbloDI&?UDm*Q4F(8+ubMo~klO?!->|YUK?i zKStYe++5n}K$N_~Fy=0)&h4MXV|Y+1~}B=$F9 zdA($NkXOb*q~6EZj4$4Gj)712e|FxGS#$t^j~**=(cRH`^J#~3V|ad#ImE=nh2+N~ zV4i*dnCvf=!W~&jacN`7d7{G+#r>c}bIqkNqg4T1(cuvfjw_>?fS3^jpj5G3v}jK? zyK62UQrRMrOY?Z!qZphCr>$F(t$}&35%3H13=ZJVZENlxCh4)7+A8VPt*4UFIm^a_ z2)}B!yz`R>u4RHtsh{I8?Am6Sm?{fs=hUsf*pEvHAs+86JXuSMjacUsnwai0v?qG# zN~~yCA^A%vtKFw%TNuKPpzmdSg13~XtvN2x(dtH;Q5p+kf4fM1VjlauScqj!gm+zE z9Wg>@{cs)kK%h;onhD4R^zc8iUZrhj;~@~GL;GSlN-$W~J=$OG=hH}G?cY{$hUbmdkc>S;Os8gLn)>+&PZ~dyVoPBkxG=$>3zz^|4j6Te-w{ zSjuHGECQvl!qt8m;Kj&#=*Hs*kz#bflYD`T!n2Pq>{IW2mxurG39315AE?>$-t+?fUJ1@4QQOSxQ>aTo1-SSt(oR>mvYAO`$&0u$c%@)h5ndi)oYH(wt&h;!dvUGHy`Di>CRaFLw{HCUjUz#_K&p&hLW~y!&pB9$3Ko{GvOb?GVbiz<8U|fU}2ZsPq!8#lM6Vj z&;xApJcE;$!8%?DoK z;o}d5CwOi*UjpRcz?&bYG7%!&9G4{6iA!k?roff zWhtKuOCIz?TE98yZ=DJo8P%=E0O(v5Q9TP(q6JfU)AmfqjD0V?MMZ0N-E}I%$s~56 z{dpY7;<*rlOtsGj>uq^^*fx7KyvT;g3us$VHo`7ysp%kKyZkUdn6W6AfbfA+a_6f( z*S)c$~r$d#d%R^U-45+Ze`- zAS6WeNCKZRloFdcX4ts#o&WWTT&NH{u1gzBe&hgG-$}5dd3`3ZZv!!}Q(7|Wtk3=Y ziLm!y!~+ieGB?8PuE*w*WBUM|SyD|tAa z+vGs=mbCHQ3F@{ab{(Jiv`OL!GLhH*h+0zxTjdE>^ltEMNw^$ z;w5`_KhE85d^bSeKUgrbQVQ8Ja~?wz`#}obt{H|LVJpX0JKgWP)qh9lJ)093wDzIL z!K8Sm!VstD@E;Q^uetBBHa$phtm?H)IEDL}Osg9g;Fw}6-%9C^T7PZP(QRx@-!J${ z?ZE!8Go5%IIoq&TSEgV6H^NM+Mc&V`hFCr5cDxZepXs}qQWU^}{;Qy$=yu%~i?U^y z?`n5Gz76+XhN~@#hiUE^E#&q{-TWjm9|-ixU$&K$d0c%*!6PaC2-MpLA>MKpUf~NFp zjho(Ad(Yq}r*k(_th*o;FN+SiokwrWtK|!A^3TmHbGVWJtY!|Mq3|?01|&Gclc&qZ zHl%^-)coXzHtfbKEZq&jJVDc^8|@*rA&bXs^)si88-p8;-xHNj+^1sh_|9* zAu&^vtS<8gH2yf4=YmecAhVIiB_UgG)ZK5_j<${ILk3BT=Z>B1^?Uztm3)^Epyux# zeev)zr5&xeD^%@sz~kOaCk?39wJ*B_AYyKhx$}x zA9Q*3EQ~VjH&Wy198K7Y2yY~_z8wzTTRE2z14l{!3QC&4@KNzGNLTF%dwi<7?gYa} zGDig^lYk5NLSK3SNx749wF;m3alVPnO789PI=5Rf4yC)TO1{aECT2a9O(K{z@7+oK zi&NWpC4#AT_Vb3xkK`lQ9>$4%y60EV=I90W_K>sLC!^}tLCi1T092AX>~#R06!1}1OTu&8$XT^FIDe^a&j9{S` zQPtF&_?1eGav{7~j4&QCF-DJWNv9kACt^i|*v^Q#YenJ;5si4QI|*u?_b!@P32M#v z1n^zA>p=|iC5Tgv*nZT+8c6%Ryy!Z0rD$x*pZ-q>^PgA_)f}5R11~Ji1_d?P*!fnMSaB-8F$XcS zO$mPTYoJm9!{pQA_OpBkAuBuS8U0d{@-^OOQbmx+8P*5M*nQ~nt~}~L#iOP+ zvfd!FhP4p=EWG%^RAcdN+=M;0X|-dVksZ-=3_gFTv1%SlObOJcM19?-&eQIQ^qJ4` zs(wn`Kh*72U95nY+V%C4nT?7w%IA-mtW17%)23wO7pK+G)`A$y|I(1LK1Zg_%UH?+ zQ+*Umes=&N`FS+tCv^?U;wlER8oEc?nDZDGQbw@g`el6Ozqrd8HAlShm3I7D`J6TG z3#`6~N{E#xkZCRSiu*iZkp|5KKE9cahg&C#r8TQ-)ffv_x_ZCs)`0Z&nr`dqYi5>}J{bFZ(3k)_ip}{O;X2 z&Ld=VRIO+@`n!oeM{o3(^ud;YmR$M*^IO8xC-^n_w}8qhKA5j^Ri%msLk~XpCD0)K zg*^YB)X=LZuDxYG=JNi}{ViJ|M5Z$picIcF^_0n<0%^2EB<*a{$SOyuU98y$&i=&S zZ03wB3@Bs@hsmrQxsg9S{Ex-B%bqJTkqWR&wHjB)|It7EVm~h2jHkx+o46Wg=tc1?a|W`I?^3Fou;hD33yXgvORXXmL3 zP9?_b68HW|21l7uC$}&Z&6!O=+^HJwuAYT3woM`>QgH|gK0tALN4N|9k-j_zjjxsw zm!W+_$?VN1fK=ujT=S-U1M*>}L*)Yd@YDSf5`;loJ{d?!&a$=sabcGqrz)R+cU*J$ zu0-sPuzn5llGwV!KClx^vopUCNdEF9;p^Leu}}NN>Tf3}tO=i`*{BZEbW2)0<3Ee1 zxlX*ne+pYb6k}~(4dyVvyoc&kDn@CArE$yDkl2WMZb9QwKZ9G_LZ-u=u-l%K`CPOfjc;F&aLjM zBfx6tQQ%|(5KIB12xalf$Q8p;jcznGkx^a~!RPsY91bP_8J?pI=~JJ^KBi(*>KrH; zVD+KDq0<9wgO901iOlMqpkvg?AY#8#lV7V5*09PsVm+M=*y_C^!4!nWx?V@&J@$lLBe_3x3ZevH-BEHu^;*2X5s=t%!jtWm>hxfDeHb(tzXE0 zw7I*QmZY)AcU&mAa!$2>B1cq>rjN4}(*orH^?4_UX<~r@jfA~x#{87EN`}r15@f#m!NhMf*fmo+7SlZhHfska$Fi=EO9ceKGCcrvlJgf} z)$^e9dkH8bWiFgqJ|J4JQ4{r`ZsAOBp)8u)qv45%NMR|G=dv0(xg$Mc&$|wEgizAx z!O&kkn6}AyAXpgS4p1dWY47A*lKR}X@b2;P>n$V9-+16^;MRaQ{2*?cZm@f_BvH`5 zq5@yBemmePGMpyG$X;skCcXbG311@en9fV1#I|~Y#5v?N1ADcI;M>TH6wE)aCfTd8)hG(CL05ctLE7+ zJ8!HU7wMH*{60VnWBzlDbP9(!*#h+8^Ze3qtRrM8k{i&$k>14#q8+(pf;ftwZs_nn zE!}*&f307I1tGECCkixLrU(OjUOPbioJ0tGh>Z}lVS23L=uHStX$0W+kdG+Ys#CPS zKU*FC5Eq^Op1*b5?!3Twq!6es=)>e)T2S3g5N}4sP6yoYnad3A?>mDmjAV@P9&A(*PESM{Y z|Dmd(H{-L)w`3)+tv{XlceqdGo>PR8f#Bm$R{FNV;$W1mT0Z?Aad4gaOKCLtR(K&zkvon z80{ZL-7uGtDuND9jK-Ug5lw$38~JwEmMp0Hjhxns^MJMNu`Snnvc*4rcu~fHF~mtc z##rKf1yOY?<+#^@k5;e+EUX2m<>JT^4gStY7STr3SfqvR0^lIZUlsjNkXskFRGh!` z$iDm|qieId*}*pZcCK#M_qShjzPB%a^*F?hIs?pxj;W%dhY#@o)~fIq5G!=1 zXePFhtX+T1=vifqUYDDo>Zel5#0lO`%~a0VaGp)0E@b2OxICJriNxTjaeICXTFOvJ zjKB!g{#~d>;`}fCH=8esh`8ImPr81=NvG)m$>;{kU%7fs2OvQ-;?RR!-uuu&Es$KR7<2NrLbT?T zr_Ut6FSnjfhfMTm9;e1T_Wom94-RHSJuha+zgNNW5(E$9MB8TNboTEU>e0jLDwbIL z-{5Iz)jh~3V-pE^4=5)AOQ1sSW45>?B$?h9Jx~FO(*5gn8PNNr^d{XEc+JK@R`8_% zrq~88k~eN)$@5TOgzUf{zUu^s}nfMPX%Ci>elFi+HufQ z5Q4=T2#eNrcRDKB-x`e-d7YEc>&Zsd(CL*}|908Xho(HW1vpM~JNXNXJ0X?&?q8Qa zmW|qoaiTVTowUI~5&v{9{#Yf2_{)E=oi%m*8Kl~JEN_p(8_H#wi1E9W_yhNA&$=Z> z+bgKn++?g+FijhAP6dB}Ae5l9fO(l@$StS#xe zM7b6n)HoG|_W+#7q#(Nx)YaZud|S9U+u>uc=#T~l{H4uq>Z@T1FD!vqTL8iBT~1L} z_lmrG@^#CKOVr&T|DB_6DsU`!BeF^5?&jer;QroK7TSu3?jAl42dW+@5;{%OI>$1# zYyS8=%DxvYIQvxY`x7fqIiS>x9qU)Rz2<788KJSw*MiQ7g>nKaE`fl^@4@FQZP>s{=R0UmV(bCflOc$KHt8M!dZ z&7PMAB+CA;vuYr5M8mgnjh}BsrzuEqyHPd(JP|;b`TRd>QlnF(%LoxTTRct$&hxof@^y z#wYY6H5!N2Pc}wsRQz?>>$hgO?35IqGtF}OxhQ0Ey1`CAsjI$};%dEor2H9oRW{RL zbhp#%s%X5^B)P&D2?DLK9BV6r2iFSH{>CBX|b z^9C%a5UrWRlaDCs)?~lmK;JxA*fR1`41OjKSkzNlv?Swm0mNS$RYQaBpt}FUL|`G8 zc;-hIF<1I4+*|E$y#jxBWr$O!Mss*EEJs}9pz_|cFMXfVLDQEtdEa?Y@ z)d~Ac%H+Ml?o8CvC9jeBFDnmT&Hfuj2B@25EAlt{IgZxNxRj8+YkK?B{Zq&{{jCt` zK6Ql;7YpxMjfRWX?WTl60XoXgTw60h`puq(RQwl;4a-?6+W7XYr61p(vC zUNLsI2fpAYX*S{KsXfENHJ2uh|LnhBt^aT~6NcQmA9Z}Q{3dexV?u=NBtXgMiMQ2k zg=oqPEK9A8^-R(Z67wjy>`*B!JAWttQZl%FU(MKFeiF}%4#k+Q!J($MVVEt(YXh>w zd#1cME^$L*9~kFd_0IOzbidsiknH9}n30p&vCaugg(qM4b1(dfi977|>e6q*^?P_H z()gSdJr?y7`2<$LwM)eF(uaqGaoOM5@dDE^DzLF~LQeR!?eT<|{W z*;RJ|lKF8%PPl&wiA{tkHOJmB#Qee-Vt7$DtMpZ_8hUA9t z_Wdg$dE%U0nfH*%SC>$6U^*e<4lrUp3NO9oBSdIn1SFbCz+seGn6?orYsFBxq_tsF zQqRLT(TUQ9*dgf`zdE8ljP@q{f+3`ilHBl@pf^r3;EHmNd9nxTavdBjNxKYx=a%pE*D+8Ob*4Q@t?fcvr?$LWFdJng4a%5VbPidr$^ z?5w!^gye8?XT^S)Pe_p=Gm;i)?MOm@heyl_=p$#=sdHhZ){9X0CAY&@_6p2gjprsB zah5e|>1^2K9KW)3lqc97rXaN5QsP|l7$?mU~A9f$H%VO`a)FRVf-&?Up;@GA=2@EO21s(*E- zgQ7;QBkFF=iF~@HQPQ5AxUDNad(zOsez#jZ_N@&9G(uaMFU7_EhO$dR4`=|H@svu%M zD9x_M2wo26?@}*uPdXi^{*2u-%*IlGX_yjj`6rq#Kmj7A@aA#R(_M zq&rkjyi)Y9759_BEo~c`LgNZO8Y1=BhHT$V0eB}{`e)^WKhXqcK7PzdOKBR~nLlx*`;K0gelG@twh)`#5 z9#N&hDq5zYzPHHx84u|# zgW2eW_KsI6Ovd?qsOs^w(>b{>I&AKo$WhAnx31}%$P{ucn?0c>NR)^LW137dK>qxC z#m!!n*IN2F-ed-2UhMfed1+iZM8<)I*2T=Un+Y~5?>8&?7whAE%$EX_b>K3>N*rO2 zCGoi@Y3#Y+_Gf(kxUa2w041~h$vX{Sa0{Gtl8^wLfd=x@daBiqHy_7zSX0o}zbn~O z;Iu^%7J6;T`M#e(#lx5L)Hi;)E04DF+6%j>ZmkpAKXQllCGwV5yD6Wl=n~huDBJ&$ zimVTcv4KZAzw?Pra}EZ-#;GH=eVuyo zktPnexghved(}{OmLsMKBioq5SE%rYbb8_@wUiB(j*c}|qEoS<{F15bsch1ic%{Ou93F4cq zm#jOqdS*AAmozA{9(+oE_sEIUeYUimv;~8zj@6fYqHk@>_LtJlRTFXFn7np$0 zOu0cAVtpY+!8~!W(yVyK*PL;Q`&~TwS@eb)c0S^mFskvuK-y`W-+24##)0xf-DyR6 zV!*Tq;Q-bNiFz`0RsduszptJ|e7b}9gsumvg-w(Ix9}z)nL&E@0&1e0r&K*l(O{ae zObi9C?Fz3yBg*}PW#m1{isnphp;*I@GbTZbA8_ZjEeh&Dw^;(cQ_vJnN9ZGZ!5OSJXv><0ys zAIrrAp=+#P^g`3u%J!pi!~L%vdG6PK!yT(8a_t{+wAL*j z5$qXS`zu!Uo+Nq~r!Pi;Uec}ar8IeT0Wmu5mwMJd4@F*(lx0&||@$ zBmXJS{@6CGTV*?C%F`H)Y#6Xyiq2!$&iJwZNc?4i%EAX9O^X2F;NB?Y^w>07ra;5% zZ_u@Lehl_2*|BE_V5NpJ5b3PewJhDEIGvhXv5;|nuf~I>AjX{gsG6J|eE*?Rn11ds zhXh~dv80;<o@O~AMq5*$kzBVvv{&w&8{`4hU{r6&MWGbc8+&$QBf#FYRxc#h zk7Vl6RsV(m{ZMri!;9d?=hyYZ^HmtBza+5pl*+uI?yM+DzxPxXJ>;Ei(vL2fy0uXm z;=5CH|MxM^s{$CnC3T`$`fxez#R^FCgi=kU{-mOHhzHGKzpNSdK^~K_0Nqq;n)Y-V z;2i@=(vkr`% z_r>&^e;uC~|9;+O28kB^Or5{}mzP?Ss72>R3P~EXww;$7;S8kg9sHQi@H+ASIBG!q zb?g#&?6$uQo_S3G5@lj(pU2f}VLv^2C-F*&2DYGd{~}mo50JTtf5|MGRKJv-$id31 z)yG|V{A@nK{W^T{_#}l1*EU!*6Cc6&-|wRvsJSaTj@|Gf5;XB%7tl@6aaNE1Z)nuK zrhOAuP7`Nfl>ri4-56qi7QcpMap+2&BH`z2ETL8xo0Cwi=SYerbCDrjd z;2Y|D1p|qG6Su#o(E8;!$bVL3#aG`u?=-;IV@dHWT*dy*d)8GHq9tZJAl*{Lpw#GJ*gVTN%08!^z_Tl#!I(THxBCe%LYpVz><_IQ9 z-QkwaBt)$pkSL&E_p}92dz_M7|8vkg*Sf_LhyhIs>F!TS%{!X$wmmyk&OINa{x~t@ zDVTJJ{c%*qu06F?!~hFALgVJ>U{mZR*K_~b>Tc}i`?-I2`ncbW`Q&QjwD;+v!2GB; z>`viZYnfgQVtBhg{KDC^g(cz}De^z7n~<5Gsw+1kv`|;HI;Xqu@N;Ar&+*lh8K>Uv zaN(guPt^;*K3s5GE5!C)qOMuo?&fIiX5j#p1B>P&`2EMv<4iAMa7E@PtlYWeQ*20?Vs|`N>V553 zd<|1z&29xnWPG#JT}5sWR9=)|9-}otspF^vXra_m*0Uy^Bu__o@YfT%VD3^*DsEWq z?9Dcw!;yK5UpEg`=DcM@zr*Pq&ntUJmM?NA8aO6{r)<<~2}gG3dn5ryT4V;g{p#xX zd|AAOp9-qbe3`t6{45p($bC<~D02n!5B_41CHZl6cNfOW&g(>8Q!6j!zI1csMue}K z_(ze$0Ew{ryCUEK_i@$&(hVz>d|h$l#QjIV|DWbr))X{K4CImw1Q(u}l8f`3iw2Pt zk2n+Eh5DiP*rPRvXz{xnsFSHDwqYYUwQ&g0{D>W~zJxEJ)4JJefZ#=VFh%Pl)>ZCz zM5vEy;ee#oVKYMb?Av3e8g=E%sLEI2^qU{sRhIJ_2Hpp7{74$*a=Ia~e+*5lHvWZ^ zm>lm}UuU!zu?Xymfc<>PGG)Gl8*lKG)4rpwt_iN2WFU2)-?{)o^&7cQ2BmurUDTJ1 zzQhOpWGHZYO@j^HCyHo$l9 ze=9$tB!C|V0eMkpgAK{o^2jz63*a}N!B$6(8H;o6H`L8b`C>(2C{-IEB^2-Z0NXfS zaE?69*~cf2l;hCWE@Eg9)|HdS+!2C5D^rxNq- z+~ufJa+=azYdu~F(ffGkH!{#kP3Zu+*_%#1mtHT4`h0+I z)~m-w`g=0}q&>^ywhL6bUGxGmRT~gcEOW-X`yDUS$BJib+Q8llUc=vt@kbxJ$Y`+zu@lX^W_Bd;9=s%9#KNAotGq{jI%kWu z1-VjE+lS(q==bgr`PkA9y1u2}Qbg^M(2vET?#ri}Jmx3MBgpU*rY<2(1e+P-zRQa! z{!0;x3;x@yYNdkIIxo7VoUZlwvJ^--mTtx+Z=6q!R;3B5lSLZU^%WdP2XC-kgV(dF z$MiBd^M~)@Lip^5P<~@=_#m7@5iinz%m7pGMezH9ve_NgIr+-25{|#7=S>+?ID{0D z+8F{S$#&6oJ)J$*oSj+^mvwb7UC&>jhiuPsr#~~uhTXNd3WSwE&SXV;fD(bZHq*#~ z=`(8LmAxCN&tm3QY$x!q*p76iD@^doJ^Kh9)xNjW&jU;)TypGsccb+}zrgn{*{ohI zKnt0`>ed858UWdS_qGgozy#PoWBvV)JitLf=@l`q<7Zy#Va2rprxd3g*}w5=1ZkkUG*2I2Nz&bmJNYfE>Ao0#ZrT zQ-L3&B|Z4s@~Bpw)c`3R;FXCJz zv9MLY-ahGny8No1W>|l5Ds^pmIMK5WIktX6?&%ee2&c-%M=HNxU0h7ouEoKplP@c* z=m`O04k&Nk)4H#TVO6B2qmBIg4PyFOF?#He=e-LGz4ou%nD{yZ8=%KF%JwIh^3`$a z$W`&dQhb^ffR$hW3K*-1X1U7r*drWvQLb)PP zNARan=dRy_?0Uy$4*`bgkbZbn!e7f;f*msZAJ)gk9Z@J*lyLrwbhG+y-5NXz1vGeo zik7WnOgrUV(ch)9_~)igXy8zaBaaH9xWkXfJy< zf7Iv8V;M?`y7vI5=LSTyE2A08Qgj7V2L zLBzbTBU<}qm%9Qv4jk;a-*2LhWv&H@U#H#u>_PVEjWPK*xu6NkHe{;|A@&$8u5~P* zbs^=Rz^lX03*1$61n6%IVGeX`9&uzb?mJ~gnt^`orm#oxV;8E7C!)-_{+t~U{Up!y z4cg`v7*{*lDwj$Or%{@gR7||;Vcot?VR|*k_*~$}`}5re*syZdq$Q3~sZj*+$@&Gc zJj6CGQBTdGy_UB&x_7g-tJhmp8fm`mYKVSR&eDIL2|?QPhZphY$2dt!T;H8B9ka}T z%F{O`y94oT`768I&h|8FICN)eN9t(npQ*{~@`-9T{pV+Jr${-Y~+Rp&@N zy!1?CAAs4PZb{c#HO?xnTQBNVhD;(*Bh`&y<5nuA+C&l)aysfhNU-&oBms(!y>1ersyGF0)I@w|sY;1u56DEM>~k zSZ9fT>+x4#Scdy;UIOzz-+?+l6O)l^_tBd2|9Ysh455<-mC{_^UcsG`^?se><0dgUW}9f&X^TBPU71lZF6qa<%TiQD##P<8d7l;hCVv3&*gkU*1YDpH>R2D^;xlWoUiI`? zxVYY~@hJM_h|V`MgrkbC`rFsoX9xA&CoC&}PIwBwPuEO+_x`+7{l;YQEUP-Na3YKL zpAy+)f3eNzkmc`2*0TKFoO9f)B{ z9}g@Ctq%gn0J({xEW~Upv+vq%b+k`+YWT;-^NjY8S4KiEq=Vsa)3JzP+bIvs^gwT2 z6#1X!kzWSsk>j?myc%A{Ump~3?$so%qZ5DK`yDm;I92>oD1IGBaj_nm%4c|u;G8Y% zjPWo>yz+3bzL}b(F(>MD^wfYuT;EVAJzI2p9zyS6%n#3WE2}f-(m5W!zh^uIe0Mzn zN4^KQ>8Dn?kmRde47^`XU6pUUdg$75sZ5{NnQd^Py_?Onui?k9c)s}h(LtpCL$2g0 zZGLP|=350u_as=x7U$1;@XMm2ei}oK&Q!KB*+9v#QU;X)PeRUag>hdLbF4n55Y2T{ zi!0d74nS?etO){6q=L{vIM6aWssEU%Mt+J=N_uFlS&oqLXH5+u<6PGE+}lCI`4Vez zJYD-G7T@sY2U1POV3&)kp4}yy+{p@ty0ClpPmIAtcy&o97Qf?(98QGl7wAWQ(3d0y>AxOD*q-&CUN_igUK3z;adQ6jRGM-2pS?v%pijYbwVj}$W zoJ17>DsgQ5gG6Mc%5dk|fc40~(*b6zq4M>!2-KwvH5bNO$-$y#5#|~9hX9kH3@xYu zzAocdLG%fxqPjs-W z7-Mh~Uvw!!D4&7QOWF(nlbTzR&u|hz^&iT^ISODZ+1Pbr#<_4azY^NPGVWuef$2n! zjdo}bodvTGW&G^Fkt_`(ZaC;7DSknwf98`9Cb^^;K)I4JD}tI8(PQMzFi-Ar^OTjj zBhpg~V5EN`9}b1F1n?>XH{P0yvq(9E0`{Ra07a18p$WQTq()o4loTop&;ctX0*e!o zY~l03JLqfoL2vS#h0b$o&)y+3OF7=RZUpu-0bW~ z7XE&*d9b=ELzOl`>brbJ{-4?vZIzu(+|CBu>VL5syf*PWF=D=m(|e5ly!IGPwW9J; zky+=!*Ns&T4m1r_g)?Zk z=dONi!~DIZUPvJ751ry$QQO*QM;*?x&@(Xmo8L)aDvvYB^PH7641-Jpdes0getIT~ zmc$+xX-FwM(s~B|y7+ST^vHbBcXT){()6dcdrx0)|Hd|YK7HmGyAWMq61>}fBffs2 z5O;GzNV}oJ58VaGyR;1^63(^`9OxYZLN7DG;xn4iKSQ@)8mUM#ccGV zrm&Su&r>(EBTki4$AL zG=%CTO4Am}wWObn;!A=v1TK>}g%F{v&mV%;OGSq`-6+DKTtJa~zR4Hf<;QHjMw49Z zQ@E%vt$!bQa!`wJ&LYrbDUgs{>CAkC2nO3X6{LJ~K zG(vLfwkC-D6lN%R4-Txn8XT2^*sHh$Kk0)_MjJrW4|7eXW8GD)zpeGL<`9J(Usg1k zGbZiqIZp6I-7$i%7L=0FMGlwN5V#t3WdM|3k3umcj|ff#RP-a>uDcKs2v~hWO+!f< zAkJ?OaRivXlFiJlXD$JFLH;4DuILT&qf5ucW3ybK%z|48B}YIr1UW^deP2fc5-IG* z0kSK%^(&+08~U68xqs~hfwQ2Hx^*JF(s#7|I9$J@?ts_4{4O5dxntJrGzAUc)!RI~ zn?W$)^n234?-58_sx*SO)F%XDT3H_{ za|Jmp0Cwvk^&lTpkyEr!%MfvVQgz^4%H+vjv3cgjiHyYey!nq%ly_-yBDD4VZW zrQ<4MWLU_O2e9!$Xmueh`8L=u3u14fS}$~~WY7JT?k!|R{>s8EaaBrDT#P12ge$`x zcn59332YzlV+Y`dpSbZxyuR$T(R|b??Q0UC{tK#r`NYt0{jirC^dI6x;&d57hWmR! ze}Ot!<*21i^EO}Y=K+r<%dcEB`x16#Y>2(?^LzlV=d&+v*P|D=MMgrHg&50sJd`zl zD>&okJnT#|DEK`1sg`opv}o>5<#tN>7>G%n;rSD*k_q`g@%K?m6QXsI)3aUt%wjtT zG7XDRy%tHg+_Ss}BNn!kqSrhRTy#(M%3LXJ1p|IlskaF*+$H;|dFgEF@lO~0-( zi@T!Acaq$GrdL3{VZTIa3srYa`l7sZNrs|CBG;W${Zqc{N_c#=P_Y=aB(pw!=%sgx zN~4{!IS~(9JeTNWiA-L7hEIBah3LxolJ2W@9o{T*;<_86)o1+j*<34oj&29QXElFH zp2iC;#-4G^xNksUg2c)nF*oKk_a9!mrWuHr8b2p7{EE!LGnMS{z5ON66)bPQB_88A)=GedcvGUuu zdc&`!rqvX2XZnAJ*`v2}o)Og+;-4mhYEvUQ3>%Dl5w~h1vNN~dCmIq$N^ko3dz5MO zmX1<6>>} z;MG&T-cBAS{o`t-l%(m6n%%cPzQ+F~Gm4toNe#p@*ldz*UX7Wpk?7+(+5dF?NWFBu z9w7NfpBH_le=(|&k9fO<)hj`klIov5)D!h5DTrp4A-x@RD|4sS0d>rdDa@mGt{;HLE2I6ujrQ5tCTZ!{Nh4Aw7OAjmNR_DJ*xkQ3{C znT9})lTa)%G1byv5uDK)%guDcDh)wfd}B{~_LjeK=l&y~O0BE&gNzGb&f3`qX&Uuk z`7Wj2o?1@kV%_hp=mnY3A0)`Vyfo~ssj$ieSJf;O%%J89mY1!P(C2oX4n+cI2r7pn zcbzF&i6IK97YH#~U`SCbU^GSIuUPYM8~sj z`O!Dwg5<91S=cfT7I~A#xH$GrH6Xy^cid2m+Exis{v1oW(KcB*hVc z9P-2=Kn{hkG3){P8dMOO2)_ccPju?8A|>VP3ITx*09%js!|hOos52S|MBuHkE-&BM zs%V%adBH#vbamJ`)0h4byUy860Qa`Nq_-OJRY(@p91I8S%M-}<(&6Znapz`^qm3-M zJ4roVXY@_X;9(@`B?`}R;=jU4HV{|`tEs&|x^3lMRe=d5JGe=M?w=g{6+WJQVcBUm z(^g$IUD)nZz#04Ghw+y^Mv{ja^=pJ~J2i9ngs;y|e%GX0KG|x*sou1_0e)jPOR^Mt z{Zy3U;B$=dj_J-5WqWv&-TOdmcBTf$pn}_X&Vd=tA1_6{elIoo2DseR7WDo|PQPk1 zKX^D^rBD&q7yVz(Qpoud>rk~?bmk)dwdo>0?)fwPt9hBI`1xA3?yr`-t9_>^%ys>R z@3ZaJ-H>*Bl8NR2gRj)x8{TG-Q2Bty8;&2>{(5RWzbfd%QkptXPlLCflVGYH2SPMo z#xRs#O7q^JXERy*M3H9b_7m21z14`Jkx_Cuk-~-bC^Kv= z9rYh_#4e8koU$OuJm6-8xJ+cJd+xwtMH|`OzK%fe#)licV9H8!a_SK?u< zlJ~^X7;d8`D=6x23VsX~z2>5Z*oQT&g3(xjK5iK~Kx|tdnI?!#6DzX>wC*~Iz6-#G zAasTZ1j|ySN($+rbyGh>;mDJxemgp+{b7t8NrM@@R&m&|n4xnN)6+yQqIBtIfEsEa zU2-iCm3n7@XC|Qb{|*7DC?&|q@=ZX7)bBgV@vkb9 z3k|C)Xx;)i5EXQz+R$DOz%l7cC_tEM#j^uQuZYtTrh#qGrkK^g*Vz+95Lm~CsUD_kB34w{$sBH86sR9a~GgYBMZ0! z&=b{&r3BXz>8<7Gp9t?)Bl7q`i4<<}_+Oz4cC7&1YZrwjw`jaPbiWMIl@|u)vb;cN z`mx#Qz!)wz>U?pWz6e_Fhx1gfYjO&;3EhD>l}*GdU(CK;#Bd=1aYSRo`z#KBBIb+3 zQl8S?sd4Rqshr_`4g}9fG=1qPj#VYRFa8p0{DS%&ZQPKyI$tm1VOsdq(#Ia`Udl_SH z-*`oN4$3O4`9)A-Ujl00REz9q94lmXRoNWBK+k>&;=V|)QltH>Ny1qCMe5)NuJYJH>p*ZLEN zv09TY97t(E9X;i!i{3tY|2@DO6`CFJmVls*5FycJ2K)o+f7Sq9fuzXP{qWZPg#MPg(4OGekV<69bBU;pLMOuTW@4e&M6aOucbU8*9fbv;#j4T zAcs4ABs4YhyIXUaX_^P9e_1}elJQE zct@#Y>=zDX`yv7Fz-c4dz<0ejc!&e*UzM_CI`6E~CT3`u;j%5!qDSxXpjU_QI{-E( zRp$O&fMh|^^N>a?t*K8WT_n{7uvlAtjuys1Z$N0h9B=ulPp*9f zRGA-Q1l719*@)~i2!NCot`msc#5bmw!)$ zcH|XUg||ubKn*go+q#mt02$S)*1F;RM^o$;{kh-}p+AIv9I~lz2*`shb@rcg>4zN1 zy46rnGNETR#3D0xN^Jb&M4mj8d~+|i-Q(fToubcrK~-admI;B}Pqt#N@3dCaduwn` z1umAdxF{cY^y<0m5f=qLv6*%wzjpIjR{3;lzW2Go2lS2>$2v|fdS~~$IYgayDxFX0 z*Mm}ltlHG;p}9<6v7Rc9lE!yU4B^(?*p_M6u8DWFy>>qatDG}%IgxMvj=%Jr3f{vt z{1q!=QZRWd1CXR`PY!+jQ@Z?6HgykgmnwQqa`6vO5xfzVs_Yk)FfW6MvzAfo9p2)_ zl%LZ1nqM2gbnFWs5N~s~;~(gz!t_L#)P(%9S@%5&ow1xj$vd`occ3 zAElq=z=p$lQ;rTY4YLT|r}x7de`GfF3kY?foz|rE)b31Y*Bh$`MG`GylxGA5rx1fV zwrv8%&^%n|E?LqzxaB*%g>le3obE;!_#T2tAjOSHYr;PSbFZIzbnn`(o$84qvd%d} zKH6173=d3yRvB!gY=1m|p~GEnA@30O<}Q$4OMROlGQ?#vS`QFB5;6yzP`Gu+-uJtP zeN`vUc}z$E?%-Qe4aQ=u3%)F})n2`U&z5zK>_yC&JTkbSrg;+loUU0p(vWLdyyyiN=zty!M{NzwU0>>$ml1`9s&p7yOvh6)@At7x_%gOs<}!n5-`9Q)SG!;D6)$oD!ns$!i z8;_9;Pzr)JA;{jq<-wbhw#dFjcpS(UiGLw`DsNTNIZe4IR6l(1SR1z;UdjVu)cX?O zBTt_8h0k|WNB=2K=D%PZ&j>ouca1J?$xC4Y4kz8H?ufzrci16!Htc$aJqhnPj)Doh zj0qQ#wGZR|qXq7d%hEv5`FEaHmHfK3_&k!ufbiUG{o9_5@8O#pOE)uy6rvJEQ^M?v; z#QeB)&EJFcEwD!3jnm%pov>r|EYSy#jTK!|(r@FoHS#iU^iW~j;BJ;Z6CSL*wB*(k zcsj`8JRKec(az&~d;O;YOhu-aP~lD1M_elOmy(bm9k&KPlw5ds!?y7LLXU{C%H^c& z1wZnT)-q8edB{|hL&(Smd-jQkfSChZ_dA9lF>rAo&Oga+;iBu`eP@aYVQD(&16VcU z`SM0m_-23MO>-n6s`N&ee^EY}3D=vMdYJtk^=B#aFSR6YpkD~XYeRxKJxhFP!O!=J z4x_4?N&h&hHiu@DArz#V9b6K8&BJV-3Qw6w5trNsufg1l1R{(!Wj}gnU2?4Q{^W@6 zt=Uyd8KOQl@`e2OA&f68H;n?^`VHLZc!toC;zDv2M{1;9!$j)iAq0WVUikpei$czIVKqdzNO z*@bt&`%{WvGqyUbpFa27P9+zVN%Y$W0b4%LXS6rcDDFt|J4PyJ$=CC>Rnn~L%{5;W zj)DYyeDs_)bYfA>i7WeZB;m*JLiT=)T^1Oaf9w23~sO7T^jq}~&m zBT4kc3mi3d9ce0a5h>~;GtN=)Ay*tpQJk&|a${68JV_Rq!=xVmfn}sgvipi>@AN{I z*oNxuE=Tt-k9I9OlXFFP_>zQcjIxsiU#t$vqcel#RbaevJ1FEyg+(zTbqZ(ae}GUT ztjN^i!lS|{_UZ1(&_wGRqL(}L7WC{ij1M{fmRCjgea+2H$0BCn5pjg zP+z9L(L09L3oUP~#Pt6XyRyG*_(~I}n;S^A@LK6dOW}FYOi^)y=Gu8F8t_G_57Nd;F;)Jm~{Xa9uXYP7!_l z%+!`P!^+1MS2x4gXPBDOR*ey#dl#sXxjy_OrDw$PANqvdA_TznpaZ>Gg~CK*5tt>S zp%EY3>aW)KOp*8R`IlwP%&DCeWMw85^*n@#wJj^W(-)onAM)v2w zwTUcC8I-R|*-qI|q$Xy=7DtWIVdu5Ds0dGh4J&s;@$vq=f39f^3KFFZ@K<=nO-3t# zogtz(afCmG0NyP zZ<1FQnDGR;>^RZsqoSEa2OUr*4}LtVztJht7K6dQ z&z(b91#%A(>FM@-0i06#x=;mt{nA25&jG-UF*Y$xulz@oYc`WO;LOZD7VG}lRk|!o z6Fll*H7dw@1FyUm?|YNEwwzc~)=P5N!O#+{`)=0JFjQ6d&5VQFxI49^T7rAz9Y@0h zrp@;^qB6J#8TtCLMP>kV9)METsWCNRnLg12yv_&HmwE?uhvae~-GPY=PzmC^ha@Bx zpa|4uAJUC=2fF0D0F1B$WcI8VV0{@bP><}YP~wm-l!&xQ^|=TL{*?4Ea~L&+&jIBU ztq4^^#C@AcBiU+dk+@;SnOEdMsuVu zMStFxVU@;<+|yI%a6Blsu@TnRPE*->hkq|ADE7iu`*7gF;l8^bW+_NOw&XCo{Omy?ycab>3j*t;mwFMRh!Oh;I(9@&Z&j^!ry}~j>qJB zrEsn;H5D(ZN<#L5=IK%=8YhIl0y5ym#3R3@iViLH zA-7+8{1e8XJwDo058r#tw|)M>aLjRxhLevu@Y5`&t?u5_1^KN6=RFJG+BZ#;lt!~( z&gNfcoK>E7|GN8oxvpt+$+Td5TCMW3^5D~1{)Lh&K%h2Dxt8HBlj8VCXxRUoU8_uN zC;jdhi3T29qkk@sxW4SwhUES6aBxbo7WV?X)epfI+o%hLj-z5>~znRsRytu-ouyl7tRE#$rM_?n4l|% zAa9LRiV(M~dfW|jD+1|!GkGTPc0yip-}WsE6@I?l<#5pw!|S{U>mFsgEalve>I1Oz zMQZ*trVTDVukecS$C{h2RBPDU%Q5~J&i`wY=BVhtK*%Rcvy$H2`;S~z(ze&0ZD!Wc zEb_1YQR7k6p#1aKAUt>NfHNVW&xYPt5Gl=}d0pWKIM80-LKP@b;(xb}Ng`Nh7I9=M zvXoB|^TAJ5R=C#Byqq|2>lyyMj)4C6sNJhT@kW#;)`fBWK%=+gZN<&kPd}&~$n`l* zhNrR2*FO_g|LlJrb+kQ5c`COe`o!)=b;gd&vke$>`ow;R8@_U+j6lyQN9!GsG%nD1 zey4gGDqF_V@oM1lFuVCg;`on1ai*2uezcDFUr4uwOY92?tHsnj9?qTEpxm!kh+X<~ zllRxoulrHy&5H~z_hQ=~74x-JKIlqf|pxq##REFXY4j_6fWKJm2>za_xj!ca+JUbZW*R=;m zSf*s;N|RBzPcn$wyl&&;EpZD2Q*PL3tPZ&_&(s%ZNCPA`zklKWlR|84bw)nPt$poG z#OhV^`v_&+P-hTCO-}EO;lRba@6>)5DyZ8xnA&G(m5 zr=+dL)lnQpUwP3dr#|#&b8vb>7f=A-NV^1YBC0XiRYqoS9axv^Z^u0X}nN+Nag%VRq!;*fPw0xt~#*2ZdgZJ=+(>I(>TOx^UG4Lp^`NiA=c)}+iR>tH zQE)NlPbO`D5H-!K+wjycTKfC7c8nHDO}%=lWYrcjdNUnX)mQB!?0~vrPwftYTWj`v z^)J81;s#t_d=NFma7W#lbJ9y8@zS$h5m3uI!;Q3Pan@KAS^HjP9{Z_$*_b(6iM5sZ zsUcC(^)>vPV?X@cMVgOv%O$xW3vb`%9n^EDcgBb6_)e+27uC&@2i46&8>6WV&0;$j z&9D>sX8D_g+-Eik80hkluH#4~zmK9T=x)|s?T@@E0CM#YTx)%qnz*M0aH=C&Pb_sj zzhY6_jR8i;0bam#(`(Ar_96EZ>e0U8u`yRqqTije&9jF(@6a}DH?GqE@&4O>_0*UH zqspS6_(`|ERqR+ZdtB(R<*DIvn)d(|QPA<)=56^qa`?MzMd42fkl6+2g~&OIi_|=l zY4vq0osNjPbnsYsh~FiP=l4Bj@m&P#$;~}`4T7LJ`&ewg8cptebrKQyfyLj{?r)nv zPT87vWgjQ{Q0=XzIa2lzRytsp$F}HhTRX=^8nj~*(@S_tgBCuaWwtGST(xO+@W%80$&k-Lh?lZ4fO#Cyi5>#SWlQo=F0 zNNAUhF4lbDF8#;nz3^!`l}NOuzTf~{D*GS6eip)nW{)P0hO-p};#~Okmk6nslY;7N z@5xjVZ^XJ%^{JTtoEA(%$`oui);>Td( zyfGdyoETZfQIkW5%mPV6ko@3&x?-9tHtQCq6*2AI`4*0q2DuR1icP@xWeDy~%Gl=B z&fX#RIy|J4gwYNMQ;DpW?Ow#Rkc^UdLXYD&I^!Rw~4Jw@UQDp$VREa#mh;+TX@97^Tz-jh?s zl%&OUd%uC_sS;QatTiHe^@`9pw$S<94ZkdP_rRxFlK4Onx^bi)z7k`7i|RS!pgTq- zdAa|Z#*2$2xgW@@f5a7PW3|M$j2!|bb=TrP@cy~dQ9<1AmMRsyQ0z?{%Q4Putq^eDzQK;qU;U5zml@`+LpuV1i`py z{uZ_fPmbGjChLQVNzwk%IObllC6s5Q(&q%ZgiAAs15~QqSbL0H>80M^XddIgNn|6> zf2yJ}(QHx`KHM*^XP)Dcxw1Ttv*o`v)}4L7b_g*ws=>r)Cvqn;%1rjZvbB`Q4`cC?zKW`c zx(ONF!ac}^8g|nT-jixoq_ccvOn$V5bonRR3pNz`TemE`RS4uF5dJ#671g&GC**6< z#MJ!RLp46gr@Qb6>Oa>Rs(xy54beQJu7lhxcZDAk@Gu{F7eO&W-{29ncHKc4}>`Nih{Wc_78~hpfV-Xau zI3CddvPrX_i8;)KKplCqQkqJ3z7mW3X5E}rADt%C*TP(r$g01i{0L3(ae1NE9ciGy zWU<@U?MI$K(IVh?A9&gwe_O4&;mM2)izHzm^6#0jzXUe7d`{D7DaQLZd|qpY%qn)RBa%NWg7G!7Yv5Ee%>Q;-pX@;;R7jRoWs5@}i(zVKQ7rT)KzcFP(k+ zE|9Piwn5y%;aN-%oSv3&&o|_zOXCKl9sGAFR*Wv~w}UsRcul=hz9(;tvr0N$69!pX zlyf_%m+c-&Bq+bTRAh+Y#~#?mp9o<)QuaHiX>r4H0ui(VRfxVzTYhPJJF0)*g(-5s zcMRUM<#Ss154C%i^6!=p@~}~UHu4R7vW)06sqOmPqKrFjE$8ot5+>M~5P70Y_c;ad z+`iK8uS+Frxhok~_WHlyF3dbj!V{j(`+a$1h};I;@BN|r_jVm7?cXC>EhMRQ-QTRl zWTQ&ry9PDPC_W}v|AXB+n^X3fdER9)GySJ^J#rBJ87#ETirf6Hr)FDgS4&5m`o$(T zI3DOPY9xz+w_Fm%T)Kf*%&=p%E?f)2i36DM56`W2LP#o6MD%ngQ{_IwirTS@%%B)T zG%iI*GHP!)j&MqqH!)+sGg--aVZ8%K7t)n*F1>+B{XGPoBYEyGc$fa49qyRBS(ldG z-28Y=7Rg+IQE`&}#+6I4@Heh!F)11Ji&W!9{JPk`DrM9Ti_V6e8Em=9(SIaAh!+N( z!V**supB0gyq(|nI7Jb@N)}@ji;}zI!`P)*s=mk*M6yS7Ja(0;`@}PNW7qq)bfU9?b~0>N zBlK5bqW|O)P;`|KW1NyFLwGF_G#!q8Z%)oUsws?QfAuN#M!Mu+^xNrr(9kY{JDx>; zb>Ca)rEybsbJM24J|7fPd`Pmt>BkiusQS9(HVwPkDMy$~Tk7sgjQ;ITDrA}k^>LgH7nIbNa#jNkoX`tkP`YM>7;@*Y+E7OkP~Kq1lUk;@COpTR5BVmFjWopH-9+va z@b(Ae4^o&_~>y|)aJzQ^WR z62o{TQ{;0&`-`EE03IIU|3K_a^l!TPX)*hD$V(k%dUD^Xki3UEBnNRxGmDfy~3@xkLyp#-~pS3+20tVh|r!k_D~I`r4l zPMmS>4J|`gXDdpst~#jvXx@0#-Ba z=8@g>y%bz3d?DT(Q`|tK%u~4f-%JUnd-;t=O7mOC;*Jk9j@8+VvhL3k43RUyj5p*- z%O4H-PHQvMxa;^mZ3y~VTn(_9upAjd%rEp)6>bDI8H=8y6m{C*Nd427wSs~6d!H0O z-lD%9^ybYLefXAKcM{j{fI;J<-HZe2hBfx`9GIUmz&AVE-9QrE%vi7CJBfr413^tMcLZYnL_IwTtUsp%l

Chimera

+

Personal PS4/PS5 platform · based on Porkfolio 1.0.9

+ +

+ Please consider + following on X + for more projects, or donating to support future development and get rid of this message. +

+ +
+ + +
+
+ +
+
+
+ + + Bitcoin +
+
+ +
+
+ +
+ + + + + +
+ + + + diff --git a/renderer/gif-encoder.js b/renderer/gif-encoder.js new file mode 100644 index 0000000..a42754f --- /dev/null +++ b/renderer/gif-encoder.js @@ -0,0 +1,338 @@ +'use strict'; +/** + * gif-encoder.js — self-contained animated GIF89a encoder + * No dependencies. Exposes global GifEncoder via IIFE. + * + * Usage: + * const enc = new GifEncoder(640, 360, { fps: 10 }); + * enc.addFrame(canvas.getContext('2d').getImageData(0,0,w,h).data); + * const bytes = enc.encode(); // Uint8Array — complete GIF89a file + * const url = URL.createObjectURL(new Blob([bytes], { type: 'image/gif' })); + */ +(function (root) { + +// ── Utilities ────────────────────────────────────────────────────────────────── +function u16le(n) { return [n & 0xFF, (n >> 8) & 0xFF]; } +function ascii(s) { + const a = new Array(s.length); + for (let i = 0; i < s.length; i++) a[i] = s.charCodeAt(i); + return a; +} + +// ── Median-cut colour quantisation ──────────────────────────────────────────── +// Returns up to maxColors {r,g,b} objects representative of the palette. +function buildPalette(frames, maxColors) { + // Sample every 4th pixel from every 2nd frame for better colour coverage + const fStep = Math.max(1, frames.length >> 1); + const seen = new Set(); + for (let fi = 0; fi < frames.length; fi += fStep) { + const d = frames[fi]; + for (let i = 0; i < d.length; i += 16) { // every 4th pixel (×4 RGBA) + seen.add((d[i] << 16) | (d[i + 1] << 8) | d[i + 2]); + } + } + + let colors = [...seen].map(c => ({ r: (c >> 16) & 0xFF, g: (c >> 8) & 0xFF, b: c & 0xFF })); + if (colors.length <= maxColors) return colors; + + // Median cut + function splitBucket(bucket) { + let rMn=255,rMx=0,gMn=255,gMx=0,bMn=255,bMx=0; + for (const c of bucket) { + if (c.r < rMn) rMn=c.r; if (c.r > rMx) rMx=c.r; + if (c.g < gMn) gMn=c.g; if (c.g > gMx) gMx=c.g; + if (c.b < bMn) bMn=c.b; if (c.b > bMx) bMx=c.b; + } + const rR=rMx-rMn, gR=gMx-gMn, bR=bMx-bMn; + const axis = rR>=gR && rR>=bR ? 'r' : gR>=bR ? 'g' : 'b'; + bucket.sort((a, b) => a[axis] - b[axis]); + const mid = bucket.length >> 1; + return [bucket.slice(0, mid), bucket.slice(mid)]; + } + + function representative(bucket) { + const n = bucket.length; + let r=0,g=0,b=0; + for (const c of bucket) { r+=c.r; g+=c.g; b+=c.b; } + return { r: (r/n+0.5)|0, g: (g/n+0.5)|0, b: (b/n+0.5)|0 }; + } + + let buckets = [colors]; + while (buckets.length < maxColors) { + let li = 0; + for (let i = 1; i < buckets.length; i++) + if (buckets[i].length > buckets[li].length) li = i; + if (buckets[li].length <= 1) break; + const [a, b] = splitBucket(buckets[li]); + buckets.splice(li, 1, a, b); + } + return buckets.map(representative); +} + +// ── 5-bit-per-channel colour lookup table (32³ = 32,768 entries) ───────────── +// 8× more accurate than the 4-bit (16³) LUT — max quantisation error per +// channel drops from ±8 to ±4 before the nearest-palette search. +function buildLUT(palette) { + const SZ = 32; + const lut = new Uint8Array(SZ * SZ * SZ); + for (let ri = 0; ri < SZ; ri++) { + for (let gi = 0; gi < SZ; gi++) { + for (let bi = 0; bi < SZ; bi++) { + const R = Math.round(ri * 255 / 31); + const G = Math.round(gi * 255 / 31); + const B = Math.round(bi * 255 / 31); + let best = 0, bestD = 1 << 30; + for (let pi = 0; pi < palette.length; pi++) { + const p = palette[pi]; + const dr = R - p.r, dg = G - p.g, db = B - p.b; + const d = dr*dr + dg*dg + db*db; + if (d < bestD) { bestD = d; best = pi; } + } + lut[ri * SZ * SZ + gi * SZ + bi] = best; + } + } + } + return lut; +} + +// ── Floyd-Steinberg dithering quantisation ──────────────────────────────────── +// Eliminates colour banding by distributing per-pixel quantisation error to +// neighbouring pixels: right 7/16, below-left 3/16, below 5/16, below-right 1/16. +function quantizeFS(rgba, w, h, palette, lut) { + const SZ = 32; + const n = w * h; + + // Float32 working buffers accumulate error without integer clamping + const R = new Float32Array(n); + const G = new Float32Array(n); + const B = new Float32Array(n); + for (let i = 0; i < n; i++) { + R[i] = rgba[i * 4]; + G[i] = rgba[i * 4 + 1]; + B[i] = rgba[i * 4 + 2]; + } + + const indices = new Uint8Array(n); + + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const i = y * w + x; + const r = Math.max(0, Math.min(255, R[i] + 0.5)) | 0; + const g = Math.max(0, Math.min(255, G[i] + 0.5)) | 0; + const b = Math.max(0, Math.min(255, B[i] + 0.5)) | 0; + + // Nearest palette entry via 5-bit LUT (O(1) lookup) + const idx = lut[(r >> 3) * SZ * SZ + (g >> 3) * SZ + (b >> 3)]; + indices[i] = idx; + + // Quantisation error + const p = palette[idx]; + const er = r - p.r, eg = g - p.g, eb = b - p.b; + + // Distribute error to neighbours + if (x + 1 < w) { + R[i + 1] += er * 0.4375; // 7/16 + G[i + 1] += eg * 0.4375; + B[i + 1] += eb * 0.4375; + } + if (y + 1 < h) { + if (x > 0) { + R[i + w - 1] += er * 0.1875; // 3/16 + G[i + w - 1] += eg * 0.1875; + B[i + w - 1] += eb * 0.1875; + } + R[i + w] += er * 0.3125; // 5/16 + G[i + w] += eg * 0.3125; + B[i + w] += eb * 0.3125; + if (x + 1 < w) { + R[i + w + 1] += er * 0.0625; // 1/16 + G[i + w + 1] += eg * 0.0625; + B[i + w + 1] += eb * 0.0625; + } + } + } + } + + return indices; +} + +// ── GIF LZW encoder ─────────────────────────────────────────────────────────── +// Returns an Array of bytes (including sub-block framing and block terminator). +function lzwEncode(pixels, minCodeSize) { + const clearCode = 1 << minCodeSize; + const eoiCode = clearCode + 1; + + // Bit-writer + const out = []; + let buf = 0, bufBits = 0; + function write(code, bits) { + buf |= (code << bufBits); + bufBits += bits; + while (bufBits >= 8) { out.push(buf & 0xFF); buf >>>= 8; bufBits -= 8; } + } + + let codeSize = minCodeSize + 1; + let nextCode = eoiCode + 1; + // Code table: maps (prefixCode << 8) | pixel → new code + const table = new Map(); + + write(clearCode, codeSize); + + if (!pixels.length) { + write(eoiCode, codeSize); + if (bufBits > 0) out.push(buf & 0xFF); + return packSubBlocks(out); + } + + let prefix = pixels[0]; + for (let i = 1; i < pixels.length; i++) { + const pix = pixels[i]; + const key = (prefix << 8) | pix; + const hit = table.get(key); + if (hit !== undefined) { + prefix = hit; + } else { + write(prefix, codeSize); + if (nextCode < 4096) { + table.set(key, nextCode); + if (nextCode === (1 << codeSize) && codeSize < 12) codeSize++; + nextCode++; + } else { + // Table full — emit clear code and reset + write(clearCode, codeSize); + table.clear(); + nextCode = eoiCode + 1; + codeSize = minCodeSize + 1; + } + prefix = pix; + } + } + write(prefix, codeSize); + write(eoiCode, codeSize); + if (bufBits > 0) out.push(buf & 0xFF); + + return packSubBlocks(out); +} + +// Pack raw bytes into GIF sub-blocks (≤255 bytes each), terminated with 0x00. +function packSubBlocks(bytes) { + const result = []; + for (let i = 0; i < bytes.length; i += 255) { + const end = Math.min(i + 255, bytes.length); + result.push(end - i); + for (let j = i; j < end; j++) result.push(bytes[j]); + } + result.push(0); // block terminator + return result; +} + +// ── GifEncoder class ────────────────────────────────────────────────────────── +class GifEncoder { + /** + * @param {number} width + * @param {number} height + * @param {{ fps?: number, maxColors?: number }} [opts] + */ + constructor(width, height, { fps = 10, maxColors = 256 } = {}) { + this.w = width; + this.h = height; + // GIF delay is in units of 1/100 s; minimum 2 (= 50 fps max) + this.delay = Math.max(2, Math.round(100 / fps)); + this.maxColors = Math.max(2, Math.min(256, maxColors | 0 || 256)); + this._frames = []; + } + + /** @param {Uint8ClampedArray} data — RGBA pixel data (width × height × 4 bytes) */ + addFrame(data) { + this._frames.push(data instanceof Uint8ClampedArray ? data : new Uint8ClampedArray(data.buffer ?? data)); + } + + get frameCount() { return this._frames.length; } + + /** + * Encode all added frames into an animated GIF. + * @returns {Uint8Array} + */ + encode() { + const { w, h, delay, _frames: frames } = this; + if (!frames.length) return new Uint8Array(0); + + // ── Build global colour palette (up to 256 colours) ──────────────────────── + let palette = buildPalette(frames, this.maxColors); + // Must be exactly a power-of-2 size between 2 and 256 + const palPow = Math.max(1, Math.ceil(Math.log2(Math.max(2, palette.length)))); + const palSize = 1 << palPow; + while (palette.length < palSize) palette.push({ r: 0, g: 0, b: 0 }); + + const minCodeSize = Math.max(2, palPow); + const lut = buildLUT(palette); + + // ── Assemble GIF binary ──────────────────────────────────────────────────── + const out = []; + function push(...items) { + for (const item of items) { + if (Array.isArray(item)) { for (const b of item) out.push(b); } + else out.push(item); + } + } + + // Header + push(ascii('GIF89a')); + + // Logical Screen Descriptor + push( + u16le(w), u16le(h), + 0x80 | (palPow - 1), // Global CT flag set; CT size = palPow−1 + 0, // Background colour index + 0, // Pixel aspect ratio + ); + + // Global Colour Table (3 bytes per entry: R, G, B) + for (const c of palette) push(c.r, c.g, c.b); + + // Application Extension — Netscape 2.0 (loop forever) + push( + 0x21, 0xFF, 0x0B, + ascii('NETSCAPE2.0'), + 0x03, 0x01, 0x00, 0x00, // loop count = 0 → infinite + 0x00, // sub-block terminator + ); + + // ── Frames ───────────────────────────────────────────────────────────────── + for (const rgba of frames) { + // Floyd-Steinberg dithered quantisation for each frame + const indices = quantizeFS(rgba, w, h, palette, lut); + + // Graphic Control Extension + push( + 0x21, 0xF9, 0x04, + 0x00, // packed: disposal = 0, no user input, no transparent + u16le(delay), // delay in 1/100 s units + 0x00, // transparent colour index (unused) + 0x00, // block terminator + ); + + // Image Descriptor + push( + 0x2C, + u16le(0), u16le(0), // left, top + u16le(w), u16le(h), + 0x00, // packed: no local CT, not interlaced + ); + + // Image Data + push(minCodeSize); + push(lzwEncode(indices, minCodeSize)); + } + + // Trailer + push(0x3B); + + return new Uint8Array(out); + } +} + +// Export +root.GifEncoder = GifEncoder; + +}(typeof window !== 'undefined' ? window : global)); diff --git a/renderer/index.html b/renderer/index.html new file mode 100644 index 0000000..10a40fe --- /dev/null +++ b/renderer/index.html @@ -0,0 +1,2607 @@ + + + + + + + Chimera + + + + + + + + + + + + + + + + + + + + + +
+
+ + + Chimera + personal edition +
+ + + Private PS platform + +
+ + + +
+
+
+ + +
+
+ +
+
—
Games
+
—
Installed
+
—
Protected
+
—
Backup files
+
+
+
+ Pinned tools +
+
+

No tools pinned yet. Use the pin on a tool to show it here.

+
+
+

PS5 connection

Offline
+
+ + + + +
+
+ + + +
+ +
+
+
+ +
+ +
+
+

Loading...

+
+
+ +
+ +
+ + + + +
+
+ + +
+ +
+
+
+ + Explicit actions only. Nothing is sent to the console automatically. +
+
+ + + +
+
+
+
—fully enriched
+
—metadata queued
+
—covers missing
+
Automaticsource check every 15 min
+
+ + +
+ + +
+ + +
+
+
+ +

+ Select your root backpork folder. Chimera will automatically scan all + firmware sub-folders inside it (e.g. 4.xx, 7.xx), + tag each game with its firmware label, and fetch metadata automatically. +

+ +
+
+ ⚙ + Auto-Generate Backporks +
+
+
+ + + Pair 1-6 apply libc patch; 7-10 revert it +
+
+ + +
+ + +
+ + + +
+
+ + +
+
+ + + +
+
+
+ Fakelib Manager + — extract .sprx/.prx from decrypted PS5 firmware + +
+
+
+ + + +
+ + + +

+ Fakelibs are .sprx/.prx system libraries from official PS5 firmware and cannot be distributed. + Decrypt a firmware PUP on a jailbroken PS5 using + ps5_pup_decrypt + to get a .PUP.dec file, then import it here. + The tool ps5-pup-unpacker (by zecoxao) will be auto-downloaded to extract the libraries. +

+
+
+
+ + +
+
+ + + +
+ +
+
+ +
+ +
+
+
+ +
+ +
+
+ +
+ Save to + + + +
+ + +
+
+ Media cache + No managed cache assets. +
+
+ + +
+
+
+
Import title mediaStreaming upload with validated type, quota and provenance.
+ + + + + + + +
+
+
+ + + + +
+
+ +
+
+ + + +
+ 0 files cached + covering 0 games +
+
+ + +
+
+ +
+ + + + + +
+
+
Loading games…
+ +
+
+
+
Loading backups…
+ +
+
+
+
+ +
+ +
+ +
+
+
+
+ + +
+
+
+
+
+
+
+
Community Hash List
+
+ 0 hashes + + +
+

The community hash list contains SHA-256 hashes of known-good game files.
If your files match — verified clean. If the list has your game but hashes differ — possible modification or brick code.

+
+
+
Your Verified Hashes
+
—
+
+
+
+ + + +
+
+
+
+
+
+ + +
+
+

Console Payload Manager

+ offline + + +
+

Manage repositories, updates and the autoload sequence through the Payload Manager running on the PS5.

+
+ + Not connected. +
+ + +
+
+
+

PS5 Service Center

+ not checked + +
+

Shows which console capabilities are currently reachable. An offline component usually means that its payload is not running on the PS5.

+
+

Services are checked when this page opens.

+
+
+
+
+

Online Payload Catalog

+ +
+

Chimera indexes reviewed PS5 projects. Community and custom sources remain clearly labelled. Downloads are bounded, use HTTPS and verify published SHA-256 checksums when available.

+
+
0payloads
+
0sources
+
0local
+
0updates
+
0checksums
+
+
+ + + + + + +
+
+
+
+
+

Chimera Payload Library & Sender

+ + +
+
+
Local Payloads
+

Files saved to your configured local payloads folder. Click ↑ FTP to push to PS5 via transfer queue.

+
+ +
+
+
PS5 Payload Sender
+

Send a payload directly to your PS5 over a raw TCP connection — no FTP needed. Uses the PS5 IP from your FTP settings. Match the port to whatever exploit host (e.g. bin-loader) is listening on the PS5.

+
+ + +
+
+ +
+
+
+ PS5 Autoloader + /data/ps5_autoloader +
+

Build and upload the autoload sequence. Payloads run top-to-bottom on jailbreak; delay rows (!ms) pause between them for stability.

+
+ + + +
+ +
+
+
Files on PS5
+
+

Click Refresh to load.

+
+
+
+
autoload.txt sequence
+
+

Click Refresh to load or build a sequence from the files on the left.

+
+ +
+
+
+
+
+ Autoloader Snapshots + backup & restore +
+

Save a snapshot of all payload files and autoload.txt from the PS5 to your PC. If the payload environment gets corrupted, restore any snapshot to recover your full setup instantly.

+
+ + + + +
+
+

No snapshots yet. Take one above.

+
+
+
+
+
+

Y2JB Update Generator

+ +
+

Create a PS5-safe y2jb_update.zip from the bundled Y2JB template. Select payloads already managed by Chimera, arrange their autoload order, then build a ZIP for USB transfer. This creates the archive locally; it does not install anything to your PS5.

+
Official Y2JB releaseNot checked yet. Discovery never uploads or applies an update.
+
+ + +
+
+
+ Chimera Payload Catalog + Uses local payloads already downloaded and checksum-tracked by the platform. +
+ +
+ + +
+
+
+ + + +
+
+

No entries yet. Add payload files, then order payloads, delays, and messages.

+
+
+ + +
+

Payload names are case-sensitive. Delays are milliseconds. The build validates the template, payload references, forward-slash ZIP paths, and archive structure before saving.

+
+
+

BDJB Genny

+

Create a Cyberpunk-themed Blu-ray Disc Java autoloader ISO from the upstream Themed BD-UN-JB Poops Autoloader template. The build stages selected ELF/BIN payloads into ps5_autoloader, writes autoload.txt, compiles the BD-J payload, and validates the resulting ISO locally.

+ +
+ + +
+

No entries yet. Add payload files, then adjust their autoload order and delays.

+
+
+
+
+

System State Controls

+

Controls require the bundled SystemStateManager payload to be running on TCP port 9112. Each system action requires an explicit confirmation phrase; no action is sent from a single click.

+
+ + + +
+
+ + + + +
+ +
+
+
+ 📺 System View + ▼ +
+
+

No capture device selected.

+
+ + +
+
+
+
+
+ +
+
+

Send Notification

+ +
+

Send a toast notification to your PS5. Uses the PS5's IP from your FTP settings.

+
+ + +
+
+ + + +
+
+
+

Settings

+

PS Notify connects to your PS5 on TCP port 6969 (customPSNotify.js). The PS5 IP is shared with FTP settings.

+
+ + + +
+
+
+

Install Custom PS Notification Payload

+

Installs the custom notification payload onto your PS5, enabling Chimera to send debug notifications directly to your console. Add customPSNotify.js to autoloader.txt and restart your PS5 to use.

+
+ + +
+
+
+

Color & Encoding Experiments

+

Tests for what the PS5 notification system can render. Emoji squares below require the patched payload (above). HTML / ANSI tests probe whether send_notification interprets markup — report back what you see.

+
+

HTML markup — tests whether send_notification passes the string through a WebKit renderer

+
+ + + +
+
+
+

Emoji colour squares — 4-byte UTF-8; garbled with stock payload, works after TextDecoder patch

+
+ + + + + +
+
+
+

ANSI-opmaak — controleert expliciete escape-sequenties zonder HTML.

+
+ + +
+
+
+ +
+
+
+
+

Notification Log

+ +
+
+
No notifications sent yet.
+
+
+
+ +
+
+ +
+ Console identity studio +

Local PS5 profile

+

Safely update a local display name or avatar with a preflight snapshot, exact confirmation, post-read verification and one-click recovery.

+
Local appearance only. Sign-in identity, activation and saves are never changed.
+
+
Checking the guarded profile bridge…
+
+
+ +
Pinned runtime

PS5Upload 5.4.19

Verifying engine, payload and PS5 ELF loader…

+
Checking…
+
+
+
+
01 · Select

Local users

+
Reading local users…
+
+
+
02 · Personalize

Choose a local user

+
+
?
+
Profile image

Load an image in the converter below, review its crop, then apply it to the selected user.

+
+ +
+
+
+
+
03 · Recover

Verified activity & recovery

Every prepared change preserves its previous state before the console is touched.

+
No prepared profile changes yet.
+
+
+
+
+ Lapy's Avatar Changer companion + Required to apply generated .xavatar files on the PS5. Chimera can now send the reviewed package directly through etaHEN DPI. + Checking installer readiness… +
+
+ + Package details +
+
+
+
+

Convert Image to .xavatar

+ +
+
+

Drop or select any image to generate a PS4/PS5-compatible .xavatar archive containing DXT5/BC3 compressed textures at 440, 260, 128, and 64 px.

+
+ + +

Drop image here, paste from clipboard, or

+
+ + +
+ + + + +
+
+
+
+
+

PS5 Avatar Library /data/AVATARS

+ + +
+
+
Connect to your PS5 and click refresh to view saved avatars.
+
+
+
+ +
+
+ + + + + + + + +
+
+
+ +
+ +
+
+
+ HDMI capture workspace +

See and record your PS5 through a capture card

+
PS5 HDMI→Capture card→Chimera
+

Connect a capture card, select it under Video above, then allow camera access. This is separate from network Remote Play.

+ Useful for low-latency monitoring, screenshots, GIFs and local recordings. +
+
+
+
+ + No device selected + + +
+
+ +
+ +
+
+ Active + 0 +
+
+ Queued + 0 +
+
+ Interrupted + 0 +
+
+ Speed + — +
+
+ Concurrent + + 1 +
+
+
+
Start windowExisting transfers continue; new jobs only start inside this optional window.
+ + + + No time window +
+
+ +
+
+ 📺 System View + ▼ +
+
+

No capture device selected.

+
+ + +
+
+
+
+
+
+
+
+
+ Local Save Manager + PS5: + (not set) + + + +
+
+ + + + + +
+
+
+ Install garlic-savemgr ELF to PS5 +
+

Uploads garlic-savemgr.elf to /data/ps5_autoloader/ and appends it to autoload.txt (if not already present). Starts a web server on port 8082. Requires FTP connection — restart your PS5 after installing. See github.com/earthonion/garlic-savemgr.

+
+ + +
+
+
+
+
+ + + + +
+
+
+
+ +
+
Configure PS5 IP in FTP Settings, then reload.
+
+
+
+
+

Select a save to browse

+

Drag & drop files onto a mounted save to add or replace them

+
+ + + + +
+
+
+ + + +
+
Terminal
+
+
+
+
+
+
+ +
+ + + + + + + + + +
+
+
+
+

Task scheduler

Safe, predefined maintenance tasks with an auditable run history.

+ — +
+

Loading tasks…

+
+
+

Recent runs

+

No runs yet.

+
+
+ + + + + + + + +
+
+ +
+

Language

+
+ + + +
+

Export the English base file, translate it, then import the translated JSON. Imported language files are stored in your user data folder and override bundled languages with the same code.

+
+
+

Console profiles

Host, service ports, component versions and pairing are kept separate for each PS5. Switching only disconnects an existing FTP session and never connects automatically.

+
+ + + +
+
+ + + +
+ +
+
+

Windows Companion Workers

Optional for FFPKG, UFS2, ExFAT and capture. HTTPS-only with HMAC pairing, registered artifact IDs and output slots; never a shell or arbitrary path.

+
+

Loading workers…

+ +
+
+

Two-factor authentication

Optional TOTP for externally reachable installations, with one-time recovery codes. Existing LAN installations remain unchanged until you enable it.

Loading status…
+
+ +
Passkeys: preparation

Loading readiness…

+
+
+

FTP Connection

These fields belong to the active console profile.

+
+ + + +
+
+
FTP Ports — tried in order until one connects
+
+ + + + +
+ +
+ +
+
+
+

Game Source Folders

+ Local drives / folders where game backups are stored +
+
+
+ +
+

Chimera scans every listed folder for .pkg files, game folders and ISOs. In Docker, the default folders are /games/ps4 and /games/ps5.

+
+
+

PS4 package preservation

Configure the pinned Fake PKG Tools 3.87 folder and a managed output vault. Chimera never accepts a custom executable or command line.

+
+
+
+
+

PS5 fPKG Backup & Restore

Bind the clean revision-pinned LibProsperoPkg bridge and a managed finalized FIH .pkg vault. Retail secrets are never generated or requested.

+
+
+

Only finalized FIH .pkg files enter the fPKG restore workflow. .ffpkg mount images remain a separate feature.

+
+
+
+

Game Conversion Mode

+ FFPKG & ExFAT require Windows +
+

+ Choose how games are packaged when you click Convert/Install on the Games page. + Raw Dumps uploads the raw game folder directly to PS5 via FTP - + FFPKG and ExFAT mode first convert the game to an image file locally, then optionally upload and delete the image to preserve space these 2 modes are Windows only. +

+
+ + + + +
+ + + + +
+
+
+

Remote Game Locations

+ FTP paths on PS5 where game folders live +
+
+
+ + + + +
+

Used during Scan PS5 and as upload targets. If none configured, defaults to standard PS5 paths. Connect to FTP before browsing.

+
+
+
+

Payload Remote Sources

+ GitHub repos or hosted pages with .bin/.js/.elf releases +
+
+
+ + + +
+
+
+

Payload Manager

+
+ + +
+

Files downloaded from GitHub sources are saved here.

+
+ + +
+

FTP path on PS5 where payloads are pushed to and deleted from.

+
+
+

Cheats Manager

+
+ + + +
+

FTP folder on PS5 where cheat JSON files are uploaded to. Must be connected to FTP before browsing.

+
+
+

Local Save Manager

+

Port that garlic-savemgr is served on from the PS5. Default is 8082.

+
+ + + +
+
+
+

Integrations

+
+
+ + + + Discord Webhook +
+

Paste a Discord channel webhook URL to share PS5 screenshots and video clips from the Media page. Screenshots are attached as images. Video clips are downloaded, converted to MP4 via ffmpeg, and uploaded to catbox.moe (free, no account needed) — a thumbnail is captured automatically during conversion.

+
+ + +
+ +
+ + + + + +
+
+
+
+

Generic notifications

Optional HTTPS JSON webhook for ntfy/Gotify bridges, automation or custom endpoints. Bearer tokens remain encrypted and are never returned.

+
+
+

+
+
+

Console identity

Observed local and linked PSN identity is displayed read-only. Chimera never edits the PS5 user database.

Read-only
+
+
Console profile
-
+
Local PS5 user
-
+
Account ID
-
+
Linked PSN Online ID
-
+
Evidence
-
+
+
Change your official Online ID

Online ID changes happen only through PlayStation account settings, never through Chimera or the console user database.

Open official guidance
+
+
+

Automation advisor

Read-only classification of mounted roots plus Save Vault and replication eligibility. Suggestions never change paths or schedules automatically.

+

Run the advisor to inspect current mounts and protection prerequisites.

+
+
+

Appearance

+
+ +
+
+ +
+ +
+ + Applies instantly across the entire app. +
+
+ + + + +
+
+
+

Performance

+
+ + + Files hashed in parallel per job. Higher = faster on fast drives; lower = less CPU/IO pressure. Default: 8. +
+
+
+
+

Web UI Server

+ +
+

+ Serve Chimera as a web app on your local network so you can access it from any browser — + including the PS5 browser — or host it on a NAS or Windows Server. + The server runs on your machine and proxies all operations through the Chimera process. +

+ +
+ + + +
+ +

+ Browse buttons that open folder dialogs will open on the server machine when used from a remote browser. + Pre-configure all paths before enabling the server for best remote experience. +

+
+
+

Metadata

+

Force re-fetch Prospero metadata for every game in the database. Clears all cached data and re-queues background fetches.

+ +
+
+

System View Hotkeys

+

Click a key field and press any key to rebind. Active when the System View page is open.

+
+ Toggle Mute + + Start / Stop GIF + + Start / Stop Video + + Toggle Fullscreen + + Pop Out Window + +
+
+

✓ All changes are saved automatically.

+
+
+ +
+

What is GarlicSaves?

+

GarlicSaves is a community-driven project that tracks gameplay statistics across jailbroken PS5 consoles. By enrolling, your console runs a background worker (garlic-worker-ps5.elf) that periodically polls for jobs

+

Enrollment is completely optional. You can enable or disable the worker at any time from this page.

+
+
+
+

Start Contributing

+ +
+

Make sure your PS5 is jailbroken and elfldr is loaded on port 9021 before clicking Start. The worker will run until you restart your PS5.

+ +
+ +
+
+ +
+

Setup Tips

+
    +
  • Your PS5 must be jailbroken and elfldr must be running on port 9021 before clicking Start Working.
  • +
  • Unavailable pending secure per-install enrollment and encrypted transport.
  • +
  • The worker runs in the background until you restart your PS5. No reboot is required to start — only to stop.
  • +
  • Go to Settings → Power Save Settings on your PS5 and disable automatic rest mode so the worker stays running.
  • +
  • View your stats and manage your account at garlicsaves.com/contribute.
  • +
+
+
+ + +
+
+ + + + + +
+
+ No connection check yet + +
+ + + + + + + + + + + + diff --git a/renderer/manifest.webmanifest b/renderer/manifest.webmanifest new file mode 100644 index 0000000..1fbaeb4 --- /dev/null +++ b/renderer/manifest.webmanifest @@ -0,0 +1,15 @@ +{ + "id": "/", + "name": "Chimera", + "short_name": "Chimera", + "description": "Private PS4/PS5 library and console-management platform.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#15111b", + "theme_color": "#15111b", + "orientation": "any", + "icons": [ + { "src": "/build/icon.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } + ] +} diff --git a/renderer/modules/autoloader.js b/renderer/modules/autoloader.js new file mode 100644 index 0000000..8738842 --- /dev/null +++ b/renderer/modules/autoloader.js @@ -0,0 +1,476 @@ +// ── PS5 Autoloader ───────────────────────────────────────────────────────────── +let _alSequence = []; // { type: 'payload'|'delay', filename?: string, ms?: number } +let _alHeader = ''; // comment block preserved from original file + +const _AL_DEFAULT_HEADER = +`# +# ps5_autoloader - autoload.txt +# Edited with Chimera +# ----------------------------------------------------------------------------------------- +# Usage: +# - Put one filename per line (e.g., payload.elf or script.js). +# - Supported payload types: .elf, .bin, .js +# - Lines starting with '!' are sleep commands (example: !1000 sleeps for 1000 ms). +# ----------------------------------------------------------------------------------------- +`; + +function _alParse(content) { + if (!content) return { header: _AL_DEFAULT_HEADER, entries: [] }; + const lines = content.split(/\r?\n/); + const headerLines = []; + const entries = []; + let headerDone = false; + for (const line of lines) { + const t = line.trim(); + if (!headerDone) { + if (t.startsWith('#') || t === '') { headerLines.push(line); continue; } + headerDone = true; + } + if (t === '' || t.startsWith('#')) continue; + if (t.startsWith('!')) { + const ms = parseInt(t.slice(1), 10); + if (!isNaN(ms)) entries.push({ type: 'delay', ms }); + } else { + entries.push({ type: 'payload', filename: t }); + } + } + const header = headerLines.join('\n').trimEnd() + '\n\n'; + return { header: header || _AL_DEFAULT_HEADER, entries }; +} + +function _alSerialize(header, entries) { + const lines = [header.trimEnd(), '']; + for (const e of entries) + lines.push(e.type === 'delay' ? `!${e.ms}` : e.filename); + return lines.join('\n') + '\n'; +} + +let _alDragIdx = -1; + +function _alSecsFmt(ms) { + const s = ms / 1000; + return s === Math.floor(s) ? s.toFixed(1) + 's' : s.toFixed(2) + 's'; +} + +function _alBuildRow(entry, i) { + const del = ``; + if (entry.type === 'delay') { + const capped = Math.min(entry.ms, 10000); + const presets = [250, 500, 1000, 2000, 3000, 5000].map(p => { + const lbl = p < 1000 ? `${p}ms` : `${p / 1000}s`; + const active = entry.ms === p ? ' al-preset-active' : ''; + return ``; + }).join(''); + return `
+ ⋮⋮ + ⏱ +
+
+ Wait + + ms + (${_alSecsFmt(entry.ms)}) +
${presets}
+
+ +
+ ${del} +
`; + } + const ext = (entry.filename.split('.').pop() || '').toLowerCase(); + const extClass = { elf: 'al-ext-elf', bin: 'al-ext-bin', js: 'al-ext-js' }[ext] || ''; + return `
+ ⋮⋮ + ${escHtml(ext || '?')} + ${escHtml(entry.filename)} + + ${del} +
`; +} + +function _alRenderSequence() { + const seq = $('autoloader-sequence'); + if (!seq) return; + if (!_alSequence.length) { + seq.innerHTML = `
+
📄
+
No entries yet.
+
Click a file on the left to add it to the sequence.
+
`; + return; + } + seq.innerHTML = _alSequence.map(_alBuildRow).join(''); + seq.querySelectorAll('.al-row[draggable]').forEach(row => { + row.addEventListener('dragstart', e => { + _alDragIdx = parseInt(row.dataset.idx, 10); + row.classList.add('al-dragging'); + e.dataTransfer.effectAllowed = 'move'; + }); + row.addEventListener('dragend', () => { + row.classList.remove('al-dragging'); + seq.querySelectorAll('.al-drag-over').forEach(r => r.classList.remove('al-drag-over')); + }); + row.addEventListener('dragover', e => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + seq.querySelectorAll('.al-drag-over').forEach(r => r.classList.remove('al-drag-over')); + if (parseInt(row.dataset.idx, 10) !== _alDragIdx) row.classList.add('al-drag-over'); + }); + row.addEventListener('drop', e => { + e.preventDefault(); + let toIdx = parseInt(row.dataset.idx, 10); + if (_alDragIdx === -1 || _alDragIdx === toIdx) return; + const [item] = _alSequence.splice(_alDragIdx, 1); + if (_alDragIdx < toIdx) toIdx--; + _alSequence.splice(toIdx, 0, item); + _alDragIdx = -1; + _alRenderSequence(); + $('btn-autoloader-save').disabled = false; + }); + }); +} + +function _alRenderFiles(files) { + const list = $('autoloader-file-list'); + if (!list) return; + const payloads = files.filter(f => !f.isDir && /\.(elf|bin|js)$/i.test(f.name)); + if (!payloads.length) { + list.innerHTML = '

No .elf / .bin / .js files found in /data/ps5_autoloader.

'; + return; + } + list.innerHTML = payloads.map(f => { + const ext = (f.name.split('.').pop() || '').toLowerCase(); + const extClass = { elf: 'al-ext-elf', bin: 'al-ext-bin', js: 'al-ext-js' }[ext] || ''; + return `
+ ${escHtml(ext)} + ${escHtml(f.name)} + + + +
`; + }).join(''); +} + +async function loadAutoloader() { + const btn = $('btn-autoloader-refresh'); + const status = $('autoloader-status'); + btn.disabled = true; + status.textContent = 'Loading…'; + try { + const files = await window.pork.autoloaderList(); + const content = await window.pork.autoloaderRead(); + const { header, entries } = _alParse(content); + _alHeader = header; + _alSequence = entries; + _alRenderFiles(files); + _alRenderSequence(); + $('btn-autoloader-save').disabled = false; + status.textContent = content + ? `Loaded \u2014 ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'}` + : 'autoload.txt not found \u2014 sequence is empty'; + } catch (e) { + status.textContent = `Error: ${e.message}`; + } finally { + btn.disabled = false; + } +} + +async function saveAutoloader() { + const btn = $('btn-autoloader-save'); + const status = $('autoloader-status'); + btn.disabled = true; + status.textContent = 'Saving\u2026'; + try { + const content = _alSerialize(_alHeader, _alSequence); + await window.pork.autoloaderSave(content); + status.textContent = 'Saved to PS5 \u2713'; + } catch (e) { + status.textContent = `Error: ${e.message}`; + btn.disabled = false; + } +} + +$('btn-autoloader-refresh').addEventListener('click', loadAutoloader); +$('btn-autoloader-save').addEventListener('click', saveAutoloader); + +const _persistButton = $('btn-pldmgr-persist-repair'); +if (_persistButton && window.pork.payloadManagerAutoloadRepairPlan) { + $('pldmgr-persistence-panel').hidden = false; + _persistButton.addEventListener('click', async () => { + const status = $('pldmgr-persistence-status'); + _persistButton.disabled = true; + status.textContent = window.t('payload.persistence.preflight'); + try { + const plan = await window.pork.payloadManagerAutoloadRepairPlan(); + if (plan.blocked) { + throw new Error(window.tf('payload.persistence.blocked', 'Other boot entries found: {entries}. Chimera will not remove them.', { entries: plan.unsupportedEntries.join(', ') })); + } + const confirmation = await showExactConfirmation( + window.t('payload.persistence.confirm'), plan.confirmation + ); + if (!confirmation) return void (status.textContent = window.t('payload.persistence.cancelled')); + status.textContent = window.t('payload.persistence.applying'); + const result = await window.pork.payloadManagerAutoloadRepairApply({ planDigest: plan.planDigest, confirmation }); + status.textContent = window.tf('payload.persistence.success', 'Persistent v0.5.1 verified. Snapshot: {snapshot}. Reboot check required.', { snapshot: result.snapshotId }); + await loadAutoloader(); + } catch (error) { + status.textContent = window.tf('payload.persistence.error', 'Persistence repair failed: {message}', { message: error.message }); + } finally { + _persistButton.disabled = false; + } + }); +} + +const _rollbackButton = $('btn-pldmgr-persist-rollback'); +if (_rollbackButton && window.pork.payloadManagerAutoloadRollbackPlan) { + _rollbackButton.addEventListener('click', async () => { + const status = $('pldmgr-persistence-status'); + _rollbackButton.disabled = true; + status.textContent = window.t('payload.persistence.rollbackPreflight'); + try { + const plan = await window.pork.payloadManagerAutoloadRollbackPlan(); + if (plan.alreadyRestored) return void (status.textContent = window.t('payload.persistence.cancelled')); + const confirmation = await showExactConfirmation(window.t('payload.persistence.rollbackConfirm'), plan.confirmation); + if (!confirmation) return void (status.textContent = window.t('payload.persistence.cancelled')); + status.textContent = window.t('payload.persistence.rollbackApplying'); + const result = await window.pork.payloadManagerAutoloadRollbackApply({ planDigest: plan.planDigest, confirmation }); + status.textContent = window.tf('payload.persistence.rollbackSuccess', 'Original boot state restored. Safety snapshot: {snapshot}. Reboot required.', { snapshot: result.safetySnapshotId }); + await loadAutoloader(); + } catch (error) { + status.textContent = window.tf('payload.persistence.error', 'Persistence repair failed: {message}', { message: error.message }); + } finally { _rollbackButton.disabled = false; } + }); +} + +function _etaHenPersistenceAction({ buttonId, plan, apply, preflight, confirm, success, snapshot = 'snapshotId' }) { + const button = $(buttonId); + if (!button || !window.pork[plan]) return; + button.addEventListener('click', async () => { + const status = $('etahen-persistence-status'); + button.disabled = true; + status.textContent = window.t(preflight); + try { + const packet = await window.pork[plan](); + const entries = [...(packet.unexpectedFiles || []), ...(packet.unsupportedEntries || [])]; + if (packet.blocked) throw new Error(window.tf('payload.etahenPersistence.blocked', 'Other boot content found: {entries}.', { entries: entries.join(', ') })); + if (packet.alreadyRestored) return void (status.textContent = window.t('payload.persistence.cancelled')); + const confirmation = await showExactConfirmation(window.t(confirm), packet.confirmation); + if (!confirmation) return void (status.textContent = window.t('payload.persistence.cancelled')); + const result = await window.pork[apply]({ planDigest: packet.planDigest, confirmation }); + status.textContent = window.tf(success, 'etaHEN 2.6B boot state updated. Snapshot: {snapshot}. Reboot required.', { snapshot: result[snapshot] }); + await loadAutoloader(); + } catch (error) { status.textContent = window.tf('payload.etahenPersistence.error', 'etaHEN persistence failed: {message}', { message: error.message }); } + finally { button.disabled = false; } + }); +} +_etaHenPersistenceAction({ buttonId: 'btn-etahen-persist', plan: 'etaHenAutoloadPersistencePlan', apply: 'etaHenAutoloadPersistenceApply', preflight: 'payload.etahenPersistence.preflight', confirm: 'payload.etahenPersistence.confirm', success: 'payload.etahenPersistence.success' }); +_etaHenPersistenceAction({ buttonId: 'btn-etahen-persist-rollback', plan: 'etaHenAutoloadPersistenceRollbackPlan', apply: 'etaHenAutoloadPersistenceRollback', preflight: 'payload.etahenPersistence.rollbackPreflight', confirm: 'payload.etahenPersistence.rollbackConfirm', success: 'payload.etahenPersistence.rollbackSuccess', snapshot: 'safetySnapshotId' }); + +function _fixedPayloadStart({ buttonId, api, exact, confirmKey, startingKey, startedKey }) { + const button = $(buttonId); + if (!button || !window.pork[api]) return; + button.addEventListener('click', async () => { + const status = $('etahen-persistence-status'); + const confirmation = await showExactConfirmation(window.t(confirmKey), exact); + if (!confirmation) return void (status.textContent = window.t('payload.persistence.cancelled')); + button.disabled = true; + status.textContent = window.t(startingKey); + try { + const result = await window.pork[api]({ confirmation }); + status.textContent = window.tf(startedKey, '', { bytes: result.bytes }); + } catch (error) { status.textContent = window.tf('payload.etahenPersistence.error', '', { message: error.message }); } + finally { button.disabled = false; } + }); +} +_fixedPayloadStart({ buttonId: 'btn-etahen-direct-start', api: 'etaHenDirectStart', exact: 'START DIRECT ETAHEN 2.6B', confirmKey: 'payload.etahenPersistence.startConfirm', startingKey: 'payload.etahenPersistence.starting', startedKey: 'payload.etahenPersistence.started' }); +_fixedPayloadStart({ buttonId: 'btn-pldmgr-verified-start', api: 'payloadManagerVerifiedStart', exact: 'START VERIFIED PS5 PAYLOAD MANAGER 0.5.1', confirmKey: 'payload.persistence.startConfirm', startingKey: 'payload.persistence.starting', startedKey: 'payload.persistence.started' }); + +$('btn-autoloader-add-delay').addEventListener('click', () => { + _alSequence.push({ type: 'delay', ms: 1000 }); + _alRenderSequence(); + $('btn-autoloader-save').disabled = false; +}); + +$('autoloader-sequence').addEventListener('click', e => { + const btn = e.target.closest('[data-al]'); + if (!btn) return; + const action = btn.dataset.al; + const idx = parseInt(btn.dataset.idx, 10); + if (action === 'remove') { + _alSequence.splice(idx, 1); + _alRenderSequence(); + $('btn-autoloader-save').disabled = false; + } else if (action === 'preset') { + const ms = parseInt(btn.dataset.ms, 10); + _alSequence[idx].ms = ms; + _alRenderSequence(); + $('btn-autoloader-save').disabled = false; + } else if (action === 'ins-delay') { + _alSequence.splice(idx + 1, 0, { type: 'delay', ms: 1000 }); + _alRenderSequence(); + $('btn-autoloader-save').disabled = false; + } +}); + +$('autoloader-sequence').addEventListener('input', e => { + const el = e.target; + const al = el.dataset.al; + if (!al) return; + const idx = parseInt(el.dataset.idx, 10); + if (al === 'delay-num' || al === 'delay-slider') { + const ms = Math.max(0, parseInt(el.value, 10) || 0); + _alSequence[idx].ms = ms; + const row = el.closest('.al-row'); + const num = row.querySelector('[data-al="delay-num"]'); + const slider = row.querySelector('[data-al="delay-slider"]'); + const secs = row.querySelector('.al-delay-secs'); + if (num) num.value = ms; + if (slider) slider.value = Math.min(ms, 10000); + if (secs) secs.textContent = `(${_alSecsFmt(ms)})`; + row.querySelectorAll('.al-preset').forEach(p => + p.classList.toggle('al-preset-active', parseInt(p.dataset.ms, 10) === ms) + ); + $('btn-autoloader-save').disabled = false; + } +}); + +// ── Autoloader Snapshots ───────────────────────────────────────────────────────────────────────────── +async function renderAutoloaderSnapshots() { + const list = $('snapshot-list'); + if (!list) return; + list.innerHTML = '

Loading…

'; + try { + const snaps = await window.pork.snapshotList(); + if (!snaps.length) { + list.innerHTML = '

No snapshots yet. Take one above.

'; + return; + } + list.innerHTML = snaps.map(s => { + const d = new Date(s.createdAt); + const dateStr = d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + const fc = (s.files || []).length; + const alStr = s.hasAutoload ? ' + autoload.txt' : ''; + return `
+
+ ${escHtml(s.label || 'Snapshot')} + ${dateStr} \u2014 ${fc} payload${fc !== 1 ? 's' : ''}${alStr} +
+
+ + + +
+
`; + }).join(''); + + list.querySelectorAll('.btn-snap-restore').forEach(btn => { + btn.addEventListener('click', async () => { + const id = btn.dataset.snapId; + const confirmation = await showExactConfirmation( + 'Restore this snapshot to your PS5?\n\nAll payload files and autoload.txt will be uploaded back to /data/ps5_autoloader, overwriting whatever is currently there.', + 'RESTORE AUTOLOADER' + ); + if (!confirmation) return; + const orig = btn.textContent; + btn.disabled = true; + btn.textContent = '⋯ Uploading…'; + try { + const res = await window.pork.snapshotRestore(id, confirmation); + btn.textContent = '✓ Restored'; + const errNote = res.errors?.length ? ` (${res.errors.length} file${res.errors.length !== 1 ? 's' : ''} failed)` : ''; + showToast(`Snapshot restored ✓ — ${res.fileCount} file${res.fileCount !== 1 ? 's' : ''} uploaded to PS5${errNote}`, res.errors?.length ? 'info' : 'ok'); + setTimeout(() => { btn.textContent = orig; btn.disabled = false; }, 3000); + } catch (er) { + btn.textContent = orig; + btn.disabled = false; + showToast('Restore failed: ' + er.message, 'error'); + } + }); + }); + + list.querySelectorAll('.btn-snap-export').forEach(btn => { + btn.addEventListener('click', async () => { + const id = btn.dataset.snapId; + const orig = btn.textContent; + btn.disabled = true; + btn.textContent = '⋯'; + try { + const res = await window.pork.snapshotExport(id); + if (res?.canceled) { btn.textContent = orig; btn.disabled = false; return; } + btn.textContent = '✓ Exported'; + showToast('Snapshot exported ✓', 'ok'); + setTimeout(() => { btn.textContent = orig; btn.disabled = false; }, 2500); + } catch (er) { + btn.textContent = orig; + btn.disabled = false; + showToast('Export failed: ' + er.message, 'error'); + } + }); + }); + + list.querySelectorAll('.btn-snap-delete').forEach(btn => { + btn.addEventListener('click', async () => { + const id = btn.dataset.snapId; + const confirmation = await showExactConfirmation('Delete this snapshot? This cannot be undone.', 'VERWIJDER'); + if (!confirmation) return; + try { + await window.pork.snapshotDelete(id, confirmation); + await renderAutoloaderSnapshots(); + } catch (er) { + showToast('Delete failed: ' + er.message, 'error'); + } + }); + }); + + } catch (e) { + list.innerHTML = `

Error: ${escHtml(e.message)}

`; + } +} + +$('btn-snapshot-import').addEventListener('click', async () => { + const btn = $('btn-snapshot-import'); + const status = $('snapshot-take-status'); + const orig = btn.textContent; + btn.disabled = true; + btn.textContent = '⋯ Importing…'; + status.textContent = ''; + try { + let res = await window.pork.snapshotImport(); + if (res?.canceled) { btn.textContent = orig; btn.disabled = false; return; } + if (res?.needsLabel) { + const label = await showPrompt('Enter a label for this snapshot:', 'Imported ZIP'); + if (label === null) { btn.textContent = orig; btn.disabled = false; return; } // user cancelled + res = await window.pork.snapshotImport({ label: label.trim() || 'Imported ZIP', filePath: res.filePath }); + if (res?.canceled) { btn.textContent = orig; btn.disabled = false; return; } + } + status.textContent = `Imported ✓ — "${res.meta.label}"`; + await renderAutoloaderSnapshots(); + } catch (e) { + status.textContent = 'Import failed: ' + e.message; + } finally { + btn.textContent = orig; + btn.disabled = false; + } +}); + +$('btn-snapshot-take').addEventListener('click', async () => { + const btn = $('btn-snapshot-take'); + const status = $('snapshot-take-status'); + const label = $('snapshot-label').value.trim() || 'Snapshot'; + btn.disabled = true; + status.textContent = 'Downloading from PS5\u2026'; + try { + const { meta } = await window.pork.snapshotTake(label); + const fc = meta.files.length; + const alStr = meta.hasAutoload ? ' + autoload.txt' : ''; + status.textContent = `Saved \u2713 \u2014 ${fc} payload${fc !== 1 ? 's' : ''}${alStr}`; + $('snapshot-label').value = ''; + await renderAutoloaderSnapshots(); + } catch (e) { + status.textContent = 'Error: ' + e.message; + } finally { + btn.disabled = false; + } +}); + diff --git a/renderer/modules/backpork-gen.js b/renderer/modules/backpork-gen.js new file mode 100644 index 0000000..c76347d --- /dev/null +++ b/renderer/modules/backpork-gen.js @@ -0,0 +1,824 @@ +// ── Auto-Backpork Generator — Renderer Module ───────────────────────────────── +// Note: $ is declared in state.js and shared across all non-module scripts. + +let _sdkPairs = []; +let _gameList = []; // current candidate list shown in picker +let _selectedGames = new Set(); // game_ids explicitly selected +let _running = false; +let _bpgenProgressState = { startedAt: 0, currentGameIndex: 0, totalGames: 0 }; +let _knownFirmwareLabels = []; +let _bpgenSettingsHydrated = false; + +// ── Fakelib Manager state ───────────────────────────────────────────────────── +let _flBaseDir = ''; +let _flPairs = []; // Array<{ pair, dir, libCount, hasLibs }> +let _flSelectedPair = null; // which pair the user wants to import for +let _flExtracting = false; + +function getSdkPairValue() { + const raw = $('bpgen-sdk-pair')?.value; + if (!raw) return null; + if (raw === 'all') return 'all'; + const num = Number(raw); + return Number.isFinite(num) ? num : null; +} + +function getActiveFakelibDir() { + const sdkPair = getSdkPairValue(); + if (!sdkPair || sdkPair === 'all') return ''; + const pair = _flPairs.find(p => p.pair === sdkPair); + return pair?.hasLibs ? pair.dir : ''; +} + +function getPairStatus(pairNumber) { + return _flPairs.find(p => p.pair === pairNumber) || null; +} + +function getPreferredFirmwareLabel(pairNumber) { + return `${pairNumber}.xx`; +} + +async function saveBpgenSettings(patch = {}) { + const payload = { + bpgenOutputDir: $('bpgen-output-dir')?.value.trim() || '', + bpgenFakelibBaseDir: $('bpgen-fl-basedir')?.value.trim() || '', + bpgenFakelibDir: $('bpgen-fakelib-dir')?.value.trim() || '', + bpgenSdkPair: $('bpgen-sdk-pair')?.value || '', + bpgenFirmwareLabel: $('bpgen-fw-label')?.value.trim() || '', + ...patch, + }; + try { + await window.pork.setSettings(payload); + } catch (e) { + console.error('[bpgen] save settings error', e); + } +} + +function restoreBpgenSettings(settings = {}) { + if (!settings || typeof settings !== 'object') return; + + if (settings.bpgenOutputDir) $('bpgen-output-dir').value = settings.bpgenOutputDir; + + if (settings.bpgenFakelibBaseDir) { + _flBaseDir = settings.bpgenFakelibBaseDir; + $('bpgen-fl-basedir').value = settings.bpgenFakelibBaseDir; + } + + if (settings.bpgenFakelibDir) { + const input = $('bpgen-fakelib-dir'); + input.value = settings.bpgenFakelibDir; + input.dataset.autoDir = ''; + } + + if (settings.bpgenSdkPair && [...$('bpgen-sdk-pair').options].some(o => o.value === String(settings.bpgenSdkPair))) { + $('bpgen-sdk-pair').value = String(settings.bpgenSdkPair); + } + + if (settings.bpgenFirmwareLabel) { + const input = $('bpgen-fw-label'); + input.value = settings.bpgenFirmwareLabel; + input.dataset.autoValue = settings.bpgenFirmwareLabel; + } +} + +function syncFirmwareLabel(force = false) { + const input = $('bpgen-fw-label'); + const sdkPair = getSdkPairValue(); + if (!input || !sdkPair) return; + if (sdkPair === 'all') { + input.dataset.autoValue = 'All'; + if (force || !input.value.trim() || input.value === input.dataset.autoValue) input.value = 'All'; + return; + } + const autoLabel = getPreferredFirmwareLabel(sdkPair); + const current = input.value.trim(); + const shouldReplace = force || !current || current === input.dataset.autoValue; + + input.dataset.autoValue = autoLabel; + if (shouldReplace) { + input.value = autoLabel; + if (_bpgenSettingsHydrated) saveBpgenSettings({ bpgenFirmwareLabel: autoLabel }); + } +} + +function autoSelectSdkPairFromFakelibs(force = false) { + const available = _flPairs.filter(p => p.hasLibs).sort((a, b) => a.pair - b.pair); + if (!available.length) return; + + const currentPair = getSdkPairValue(); + if (currentPair === 'all') return; + const currentReady = available.some(p => p.pair === currentPair); + if (!force && currentReady) return; + + $('bpgen-sdk-pair').value = String(available[0].pair); + if (_bpgenSettingsHydrated) saveBpgenSettings({ bpgenSdkPair: String(available[0].pair) }); + syncFakelibInput(true); + syncFirmwareLabel(true); +} + +function syncFakelibInput(force = false) { + const input = $('bpgen-fakelib-dir'); + if (!input) return; + const current = input.value.trim(); + const autoDir = getActiveFakelibDir(); + const shouldReplace = force || !current || current === input.dataset.autoDir; + + input.dataset.autoDir = autoDir || ''; + if (shouldReplace) { + input.value = autoDir || ''; + if (_bpgenSettingsHydrated) saveBpgenSettings({ bpgenFakelibDir: autoDir || '' }); + } +} + +function resetBpgenActionButtons() { + [ + 'bpgen-btn-output', + 'bpgen-btn-fakelib', + 'bpgen-btn-run', + 'bpgen-btn-cancel', + 'bpgen-select-all', + 'bpgen-select-none', + 'bpgen-fl-btn-base', + 'bpgen-fl-btn-pick-pup', + 'bpgen-fl-btn-extract', + ].forEach(id => { + const el = $(id); + if (!el || !el.parentNode) return; + const clean = el.cloneNode(true); + clean.removeAttribute('onclick'); + clean.dataset.boundInBackporks = ''; + el.parentNode.replaceChild(clean, el); + }); +} + +// ── Init ───────────────────────────────────────────────────────────────────── +async function initBackporkGen() { + try { + const [sdkPairs, knownFirmwareLabels, savedSettings] = await Promise.all([ + window.pork.bpgenSdkPairs(), + window.pork.firmwareLabels().catch(() => []), + window.pork.getSettings().catch(() => ({})), + ]); + _sdkPairs = sdkPairs; + _knownFirmwareLabels = knownFirmwareLabels; + renderSdkPairDropdown(); + restoreBpgenSettings(savedSettings); + _bpgenSettingsHydrated = true; + syncFakelibInput(true); + syncFirmwareLabel(true); + } catch (e) { + console.error('[bpgen] init error', e); + } + + // Selection mode toggle + $('bpgen-mode').addEventListener('change', () => updateGamePickerVisibility()); + $('bpgen-sdk-pair').addEventListener('change', () => { + saveBpgenSettings({ bpgenSdkPair: $('bpgen-sdk-pair').value }); + syncFakelibInput(); + syncFirmwareLabel(); + }); + $('bpgen-output-dir').addEventListener('change', () => saveBpgenSettings()); + $('bpgen-fakelib-dir').addEventListener('change', () => saveBpgenSettings()); + $('bpgen-fl-basedir').addEventListener('change', () => saveBpgenSettings()); + + // Firmware filter change → refresh game list + $('bpgen-fw-filter').addEventListener('change', () => refreshGameList()); + + // Buttons + $('bpgen-btn-output').addEventListener('click', async e => { + e.preventDefault(); + e.stopPropagation(); + try { + const p = await window.pork.selectFolder(); + if (p) { + $('bpgen-output-dir').value = p; + saveBpgenSettings({ bpgenOutputDir: p }); + } + } catch (err) { + bpgenSetStatus(`Output folder browse failed: ${err.message}`, 'error'); + } + }); + $('bpgen-btn-fakelib').addEventListener('click', async e => { + e.preventDefault(); + e.stopPropagation(); + try { + const p = await window.pork.selectFolder(); + if (p) { + const input = $('bpgen-fakelib-dir'); + input.value = p; + input.dataset.autoDir = ''; + saveBpgenSettings({ bpgenFakelibDir: p }); + } + } catch (err) { + bpgenSetStatus(`Fakelib folder browse failed: ${err.message}`, 'error'); + } + }); + $('bpgen-btn-run').addEventListener('click', () => runBackporkGen()); + $('bpgen-btn-cancel').addEventListener('click', () => { + window.pork.bpgenCancel(); + appendLog('Cancelling…'); + }); + + // Select all / none in game picker + $('bpgen-select-all').addEventListener('click', () => toggleAllGames(true)); + $('bpgen-select-none').addEventListener('click', () => toggleAllGames(false)); + + // Listen for progress events + window.pork.on('bpgen:progress', handleProgress); + + // ── Fakelib Manager ──────────────────────────────────────────────────────── + initFakelibManager(); + resetBpgenActionButtons(); +} + +// ── SDK Pair Dropdown ───────────────────────────────────────────────────────── +function renderSdkPairDropdown() { + const sel = $('bpgen-sdk-pair'); + const current = sel.value; + sel.innerHTML = ''; + const allOpt = document.createElement('option'); + allOpt.value = 'all'; + allOpt.textContent = 'All SDK Targets'; + sel.appendChild(allOpt); + for (const p of _sdkPairs) { + const opt = document.createElement('option'); + opt.value = p.pair; + const pairStatus = getPairStatus(p.pair); + const suffix = pairStatus?.hasLibs ? ` [libs ready: ${pairStatus.libCount}]` : ''; + opt.textContent = `SDK Pair ${p.pair} — PS5: ${p.ps5Ver} / PS4: ${p.ps4Ver}${suffix}`; + sel.appendChild(opt); + } + if (current && [...sel.options].some(o => o.value === current)) sel.value = current; +} + +// ── Game Picker ─────────────────────────────────────────────────────────────── +function updateGamePickerVisibility() { + const mode = $('bpgen-mode').value; + $('bpgen-game-picker').hidden = (mode !== 'specific'); + $('bpgen-fw-filter-row').hidden = (mode !== 'fw-missing'); + if (mode === 'specific') refreshGameList(); +} + +async function refreshGameList() { + const mode = $('bpgen-mode').value; + const fw = $('bpgen-fw-filter').value.trim(); + + try { + if (mode === 'specific' || mode === 'fw-missing') { + $('bpgen-game-list').innerHTML = '
Loading…
'; + const games = mode === 'specific' + ? await window.pork.bpgenListAll() + : await window.pork.bpgenListMissing(fw); + _gameList = games; + renderGamePicker(games); + } + } catch (e) { + console.error('[bpgen] list error', e); + } +} + +function renderGamePicker(games) { + const el = $('bpgen-game-list'); + if (!games.length) { + el.innerHTML = '
No games found
'; + return; + } + el.innerHTML = games.map(g => { + const title = escHtml(g.prospero_name || g.title || g.game_id); + const checked = _selectedGames.has(g.game_id) ? 'checked' : ''; + return ``; + }).join(''); + + el.querySelectorAll('input[type=checkbox]').forEach(cb => { + cb.addEventListener('change', e => { + const id = e.target.dataset.gameId; + if (e.target.checked) _selectedGames.add(id); + else _selectedGames.delete(id); + }); + }); +} + +function toggleAllGames(select) { + _selectedGames = select ? new Set(_gameList.map(g => g.game_id)) : new Set(); + $('bpgen-game-list').querySelectorAll('input[type=checkbox]').forEach(cb => { + cb.checked = select; + }); +} + +// ── Run ─────────────────────────────────────────────────────────────────────── +async function runBackporkGen() { + const mode = $('bpgen-mode').value; + const sdkPair = getSdkPairValue(); + const outputDir = $('bpgen-output-dir').value.trim(); + const firmwareLabel = $('bpgen-fw-label').value.trim(); + const fakelibDir = $('bpgen-fakelib-dir').value.trim() || null; + const overwrite = $('bpgen-overwrite').checked; + + if (!outputDir) { bpgenSetStatus('Select an output folder first.', 'error'); return; } + if (sdkPair !== 'all' && !firmwareLabel) { bpgenSetStatus('Enter a firmware label (e.g. 4.xx).', 'error'); return; } + if (mode === 'fw-missing' && !$('bpgen-fw-filter').value.trim()) { + bpgenSetStatus('Enter the firmware label to check for missing backporks.', 'error'); + return; + } + + let gameIds = []; + let filterFw = ''; + + if (mode === 'specific') { + gameIds = [..._selectedGames]; + if (!gameIds.length) { bpgenSetStatus('Select at least one game.', 'error'); return; } + } else if (mode === 'fw-missing') { + filterFw = $('bpgen-fw-filter').value.trim(); + } + // mode === 'all-missing' → gameIds empty, filterFw empty → default all-missing logic + + _running = true; + $('bpgen-btn-run').disabled = true; + $('bpgen-btn-cancel').disabled = false; + $('bpgen-log').innerHTML = ''; + $('bpgen-progress-wrap').hidden = false; + _bpgenProgressState = { startedAt: Date.now(), currentGameIndex: 0, totalGames: 0 }; + setProgress(0, 'Starting…'); + + try { + const result = await window.pork.bpgenRun({ + gameIds, sdkPair, outputDir, firmwareLabel, fakelibDir, filterFw, overwrite, + }); + bpgenSetStatus(`Done — ${result.ok} file(s) processed, ${result.failed} failed`, result.failed ? 'warn' : 'ok'); + } catch (e) { + bpgenSetStatus(`Error: ${e.message}`, 'error'); + } finally { + _running = false; + $('bpgen-btn-run').disabled = false; + $('bpgen-btn-cancel').disabled = true; + } +} + +// ── Progress handling ───────────────────────────────────────────────────────── +function handleProgress({ type, ...data }) { + switch (type) { + case 'start': { + _bpgenProgressState.totalRuns = data.total || 0; + _bpgenProgressState.totalGames = data.games || 0; + _bpgenProgressState.totalPairs = data.pairs || 1; + _bpgenProgressState.currentGameIndex = 0; + const pairsLabel = data.pairs > 1 ? ` × ${data.pairs} SDK pairs = ${data.total} total operations` : ''; + setProgress(0, `Processing ${data.games} game(s)${pairsLabel}…`); + appendLog(`Starting: ${data.games} game(s)${pairsLabel}`); + if (data.noLocalFolder > 0) { + appendLog(` ⚠ ${data.noLocalFolder} game(s) skipped — no local folder recorded in DB`); + } + break; + } + case 'run-config': + appendLog(`Target firmware: ${data.firmwareLabel}`); + appendLog(`SDK pair: ${data.sdkPair}`); + appendLog(`Output: ${data.outputDir}`); + appendLog(`Fakelib: ${data.fakelibDir || 'none'}`); + appendLog(`Overwrite existing output: ${data.overwrite ? 'yes' : 'no'}`); + break; + case 'pair-start': { + const pct = Math.round(((data.pairIndex - 1) / (data.totalPairs || 1)) * 100); + const pairLabel = `pair ${data.pairIndex}/${data.totalPairs}`; + const gamesNote = data.gamesInPair === 0 ? ' — all games already done, skipping' : ` — ${data.gamesInPair} game(s) to process`; + // Update progress bar with pair info so the counter always advances visibly + _bpgenProgressState.currentGameLabel = `[${pairLabel}] SDK ${data.sdkPair} (FW ${data.firmwareLabel})`; + setProgress(pct, `${_bpgenProgressState.currentGameLabel} · ${formatElapsed()}`); + appendLog(`\u27A4 ${pairLabel}: SDK ${data.sdkPair}${gamesNote}`); + break; + } + case 'game-start': { + _bpgenProgressState.currentGameIndex = data.index || 0; + // Use per-pair counters when available (all-pairs mode), else fall back to overall index + const gi = data.pairGameIndex || data.index; + const gt = data.pairTotal || _bpgenProgressState.totalGames; + const pct = Math.round(((data.index - 1) / (data.total || 1)) * 100); + // Always show pair counter so single-pair runs show "pair 1/1" and all-pairs show "pair 3/10" + const pairLabel = data.totalPairs >= 1 ? ` · pair ${data.pairIndex}/${data.totalPairs}` : ''; + // Store sticky label so file-progress never overwrites it with a file path + _bpgenProgressState.currentGameLabel = `[${gi}/${gt}${pairLabel}] ${data.title || data.gameId}`; + setProgress(pct, `${_bpgenProgressState.currentGameLabel} · ${formatElapsed()}`); + appendLog(`\u25B6 [${gi}/${gt}${pairLabel}] ${data.gameId} — ${data.title || ''} (SDK ${data.sdkPair}, FW ${data.firmwareLabel})`); + break; + } + case 'file-progress': + handleFileProgress(data); + break; + case 'game-done': + appendLog(` \u2713 OK (${data.ok} files processed)`); + break; + case 'game-skip': + appendLog(` \u23ED Skipped: ${data.reason}`); + break; + case 'game-error': + appendLog(` \u2717 Error: ${data.msg}`, 'error'); + break; + case 'done': + setProgress(100, `Complete — ${data.ok} files OK, ${data.failed} failed`); + appendLog(''); + appendLog(`Complete: total=${data.total} ok=${data.ok} failed=${data.failed} skipped=${data.skipped}`); + appendLog(`Elapsed: ${formatElapsed()}`); + // Trigger a backpork rescan so the UI updates + window.pork.backporksScanAll().catch(() => {}); + break; + case 'cancelled': + setProgress(0, 'Cancelled'); + appendLog(`Cancelled after ${data.processed} game(s)`); + break; + } +} + +function handleFileProgress(data) { + if (typeof data.fileIndex === 'number' && typeof data.totalFiles === 'number' && _bpgenProgressState.totalRuns > 0) { + const currentGameZeroBased = Math.max(0, (_bpgenProgressState.currentGameIndex || 1) - 1); + const perGameFraction = data.totalFiles > 0 ? (data.fileIndex / data.totalFiles) : 0; + const pct = Math.round(((currentGameZeroBased + perGameFraction) / _bpgenProgressState.totalRuns) * 100); + // Always show game name + pair info — never the file path + const label = _bpgenProgressState.currentGameLabel || `[${_bpgenProgressState.currentGameIndex}/${_bpgenProgressState.totalGames}]`; + setProgress(Math.min(99, Math.max(0, pct)), `${label} · ${formatElapsed()}`); + } + + switch (data.step) { + case 'scan': + appendLog(` ⟳ ${data.msg}`); + break; + case 'inventory': + appendLog(` Found ${data.executableFiles} ELF/SELF file(s) to process`); + break; + case 'copy': + appendLog(` → Copy ${data.file}`); + break; + case 'process': + appendLog(` • ${data.file}`); + break; + case 'decrypt': + case 'patch': + case 'sign': + appendLog(` ${data.step}: ${data.file} — ${data.msg}`); + break; + case 'skip': + appendLog(` ↷ ${data.file}: ${data.msg}`); + break; + case 'warn': + appendLog(` ! ${data.file || 'step'}: ${data.msg}`); + break; + case 'error': + appendLog(` \u2717 ${data.file}: ${data.msg}`, 'error'); + break; + case 'libc': + appendLog(` libc: ${data.msg}${typeof data.totalFiles === 'number' ? ` (${data.totalFiles} file(s))` : ''}`); + break; + case 'libc-progress': + appendLog(` libc [${data.fileIndex}/${data.totalFiles}]: ${data.file} — ${data.msg}`); + break; + case 'fakelib': + appendLog(` fakelib: ${data.msg}`); + break; + case 'fakelib-progress': + appendLog(` fakelib [${data.fileIndex}/${data.totalFiles}]: ${data.file}`); + break; + } +} + +// ── UI helpers ──────────────────────────────────────────────────────────────── +function bpgenSetStatus(msg, type = '') { + const el = $('bpgen-status'); + el.textContent = msg; + el.className = 'hint bpgen-status ' + (type === 'error' ? 'status-error' : type === 'ok' ? 'status-ok' : type === 'warn' ? 'status-warn' : ''); +} + +function setProgress(pct, label) { + $('bpgen-progress-bar').style.width = `${pct}%`; + $('bpgen-progress-label').textContent = label; +} + +function appendLog(line, type = '') { + const el = $('bpgen-log'); + const row = document.createElement('div'); + row.className = 'bpgen-log-row' + (type === 'error' ? ' bpgen-log-error' : ''); + row.textContent = line; + el.appendChild(row); + while (el.childNodes.length > 800) el.removeChild(el.firstChild); + el.scrollTop = el.scrollHeight; +} + +function escHtml(s) { + return String(s ?? '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); +} + +// ── Fakelib Manager ─────────────────────────────────────────────────────────── + +function initFakelibManager() { + // Base dir picker + $('bpgen-fl-btn-base').addEventListener('click', async e => { + e.preventDefault(); + e.stopPropagation(); + try { + const dir = await window.pork.selectFolder(); + if (!dir) return; + const result = await window.pork.bpgenFakelibSetBasedir(dir); + if (!result) return; + _flBaseDir = result.baseDir; + _flPairs = result.pairs; + $('bpgen-fl-basedir').value = _flBaseDir; + saveBpgenSettings({ bpgenFakelibBaseDir: _flBaseDir }); + renderSdkPairDropdown(); + renderFlGrid(); + updateFlSummaryBadge(); + autoSelectSdkPairFromFakelibs(true); + syncFirmwareLabel(true); + syncFakelibInput(true); + } catch (err) { + flSetStatus(`Base directory browse failed: ${err.message}`, 'error'); + } + }); + + // Pick PUP file + $('bpgen-fl-btn-pick-pup').addEventListener('click', async e => { + e.preventDefault(); + e.stopPropagation(); + try { + const p = await window.pork.bpgenFakelibPickPup(); + if (p) $('bpgen-fl-pup-path').value = p; + } catch (err) { + flSetStatus(`PUP browse failed: ${err.message}`, 'error'); + } + }); + + // Extract button + $('bpgen-fl-btn-extract').addEventListener('click', () => runFlExtract()); + + // Progress events from main process + window.pork.on('bpgen:fakelib-progress', handleFlProgress); + + // Load saved state + window.pork.bpgenFakelibScan().then(({ baseDir, pairs }) => { + if (!baseDir) return; + _flBaseDir = baseDir; + _flPairs = pairs; + $('bpgen-fl-basedir').value = _flBaseDir; + renderSdkPairDropdown(); + renderFlGrid(); + updateFlSummaryBadge(); + autoSelectSdkPairFromFakelibs(); + syncFakelibInput(true); + syncFirmwareLabel(true); + }).catch(() => {}); +} + +function renderFlGrid() { + const grid = $('bpgen-fl-grid'); + if (!_flBaseDir || !_flPairs.length) { + _flSelectedPair = null; + grid.hidden = true; + $('bpgen-fl-import-row').hidden = true; + renderSdkPairDropdown(); + return; + } + grid.hidden = false; + grid.innerHTML = _flPairs.map(p => { + const cls = p.hasLibs ? 'bpgen-fl-pair-ok' : 'bpgen-fl-pair-missing'; + const icon = p.hasLibs ? '✓' : '+'; + const label = p.hasLibs ? `${p.libCount} lib${p.libCount !== 1 ? 's' : ''}` : 'No libs'; + return ``; + }).join(''); + + grid.querySelectorAll('.bpgen-fl-pair').forEach(btn => { + btn.addEventListener('click', () => { + _flSelectedPair = Number(btn.dataset.pair); + grid.querySelectorAll('.bpgen-fl-pair').forEach(b => b.classList.remove('bpgen-fl-pair-active')); + btn.classList.add('bpgen-fl-pair-active'); + $('bpgen-fl-import-pair-label').textContent = _flSelectedPair; + $('bpgen-fl-import-row').hidden = false; + $('bpgen-sdk-pair').value = String(_flSelectedPair); + syncFakelibInput(true); + syncFirmwareLabel(true); + }); + }); +} + +function updateFlSummaryBadge() { + const badge = $('bpgen-fl-summary-status'); + if (!_flPairs.length) { badge.textContent = ''; return; } + const ok = _flPairs.filter(p => p.hasLibs).length; + badge.textContent = `${ok}/10 pairs ready`; + badge.className = 'bpgen-fl-badge ' + (ok > 0 ? 'bpgen-fl-badge-ok' : 'bpgen-fl-badge-warn'); +} + +async function runFlExtract() { + if (_flExtracting) return; + const pupPath = $('bpgen-fl-pup-path').value.trim(); + if (!pupPath) { flSetStatus('Select a .PUP.dec file first.'); return; } + if (!_flSelectedPair) { flSetStatus('Select an SDK pair above first.'); return; } + if (!_flBaseDir) { flSetStatus('Set a Fakelib Base Directory first.'); return; } + + _flExtracting = true; + $('bpgen-fl-btn-extract').disabled = true; + $('bpgen-fl-progress-wrap').hidden = false; + flSetProgress(0, 'Starting…'); + + try { + const result = await window.pork.bpgenFakelibExtract({ + pupPath, + sdkPair: _flSelectedPair, + }); + flSetStatus(`Done — ${result.libCount} libraries extracted to pair ${_flSelectedPair}`, 'ok'); + // Refresh grid + const scan = await window.pork.bpgenFakelibScan(); + _flPairs = scan.pairs; + renderSdkPairDropdown(); + renderFlGrid(); + updateFlSummaryBadge(); + autoSelectSdkPairFromFakelibs(true); + syncFakelibInput(true); + syncFirmwareLabel(true); + // Re-select same pair to keep import row visible + const btn = $('bpgen-fl-grid')?.querySelector(`[data-pair="${_flSelectedPair}"]`); + if (btn) { btn.classList.add('bpgen-fl-pair-active'); $('bpgen-fl-import-row').hidden = false; } + } catch (e) { + flSetProgress(0, ''); + flSetStatus(`Error: ${e.message}`, 'error'); + } finally { + _flExtracting = false; + $('bpgen-fl-btn-extract').disabled = false; + } +} + +function handleFlProgress({ type, ...data }) { + switch (type) { + case 'start': + flSetProgress(0, `Preparing extraction for pair ${data.sdkPair}…`); + break; + case 'tool-progress': + flSetProgress(data.pct, data.msg); + break; + case 'tool-manual': + flSetStatus(`Manual download required — ${data.msg}`, 'error'); + break; + case 'extract-progress': + flSetProgress(data.pct, data.msg); + break; + case 'done': + flSetProgress(100, `Complete — ${data.libCount} libraries in pair ${data.sdkPair}`); + break; + case 'error': + flSetProgress(0, ''); + flSetStatus(`Error: ${data.msg}`, 'error'); + break; + } +} + +function flSetProgress(pct, label) { + $('bpgen-fl-progress-bar').style.width = `${pct}%`; + $('bpgen-fl-progress-label').textContent = label; +} + +function flSetStatus(msg, type = '') { + // Reuse bpgen-status element for simplicity, prefixed with [FL] + const el = $('bpgen-status'); + el.textContent = msg ? `[Fakelib] ${msg}` : ''; + el.className = 'hint bpgen-status ' + + (type === 'error' ? 'status-error' : type === 'ok' ? 'status-ok' : type === 'warn' ? 'status-warn' : ''); +} + +function formatElapsed() { + if (!_bpgenProgressState.startedAt) return '0s'; + const totalSeconds = Math.max(0, Math.floor((Date.now() - _bpgenProgressState.startedAt) / 1000)); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`; +} + +async function browseBpgenOutputFolder() { + try { + const p = await window.pork.selectFolder(); + if (p) { + $('bpgen-output-dir').value = p; + saveBpgenSettings({ bpgenOutputDir: p }); + } + } catch (err) { + bpgenSetStatus(`Output folder browse failed: ${err.message}`, 'error'); + } +} + +async function browseBpgenFakelibFolder() { + try { + const p = await window.pork.selectFolder(); + if (p) { + const input = $('bpgen-fakelib-dir'); + input.value = p; + input.dataset.autoDir = ''; + saveBpgenSettings({ bpgenFakelibDir: p }); + } + } catch (err) { + bpgenSetStatus(`Fakelib folder browse failed: ${err.message}`, 'error'); + } +} + +async function browseBpgenFakelibBaseDir() { + try { + const dir = await window.pork.selectFolder(); + if (!dir) return; + const result = await window.pork.bpgenFakelibSetBasedir(dir); + if (!result) return; + _flBaseDir = result.baseDir; + _flPairs = result.pairs; + $('bpgen-fl-basedir').value = _flBaseDir; + saveBpgenSettings({ bpgenFakelibBaseDir: _flBaseDir }); + renderSdkPairDropdown(); + renderFlGrid(); + updateFlSummaryBadge(); + autoSelectSdkPairFromFakelibs(true); + syncFakelibInput(true); + syncFirmwareLabel(true); + } catch (err) { + flSetStatus(`Base directory browse failed: ${err.message}`, 'error'); + } +} + +async function browseBpgenPupFile() { + try { + const p = await window.pork.bpgenFakelibPickPup(); + if (p) $('bpgen-fl-pup-path').value = p; + } catch (err) { + flSetStatus(`PUP browse failed: ${err.message}`, 'error'); + } +} + +document.addEventListener('click', e => { + const btn = e.target.closest('#bpgen-btn-output, #bpgen-btn-fakelib, #bpgen-btn-run, #bpgen-btn-cancel, #bpgen-select-all, #bpgen-select-none, #bpgen-fl-btn-base, #bpgen-fl-btn-pick-pup, #bpgen-fl-btn-extract'); + if (!btn) return; + + e.preventDefault(); + e.stopPropagation(); + + switch (btn.id) { + case 'bpgen-btn-output': + browseBpgenOutputFolder(); + break; + case 'bpgen-btn-fakelib': + browseBpgenFakelibFolder(); + break; + case 'bpgen-btn-run': + runBackporkGen(); + break; + case 'bpgen-btn-cancel': + window.pork.bpgenCancel(); + appendLog('Cancelling…'); + break; + case 'bpgen-select-all': + toggleAllGames(true); + break; + case 'bpgen-select-none': + toggleAllGames(false); + break; + case 'bpgen-fl-btn-base': + browseBpgenFakelibBaseDir(); + break; + case 'bpgen-fl-btn-pick-pup': + browseBpgenPupFile(); + break; + case 'bpgen-fl-btn-extract': + runFlExtract(); + break; + } +}, true); + +window._bpgenButtonClick = action => { + switch (action) { + case 'output': + browseBpgenOutputFolder(); + break; + case 'fakelib': + browseBpgenFakelibFolder(); + break; + case 'baseDir': + browseBpgenFakelibBaseDir(); + break; + case 'pup': + browseBpgenPupFile(); + break; + case 'extract': + runFlExtract(); + break; + case 'run': + runBackporkGen(); + break; + case 'cancel': + window.pork.bpgenCancel(); + appendLog('Cancelling…'); + break; + } + return false; +}; + +// Export for use by main renderer +window._bpgenInit = initBackporkGen; diff --git a/renderer/modules/backporks.js b/renderer/modules/backporks.js new file mode 100644 index 0000000..5066575 --- /dev/null +++ b/renderer/modules/backporks.js @@ -0,0 +1,624 @@ +// ── Backporks page ───────────────────────────────────────────────────────────── +let _bpgenPageInitStarted = false; +let _bpgenPageInitPromise = null; + +let _bpApplyUpdater = null; +window.pork.on('transfer:update', st => { _bpApplyUpdater?.(st); }); + +function renderBpgenSdkOptions(pairs, currentValue = '') { + const sdkSelect = $('bpgen-sdk-pair'); + if (!sdkSelect) return; + sdkSelect.innerHTML = ''; + const allOpt = document.createElement('option'); + allOpt.value = 'all'; + allOpt.textContent = 'All SDK Targets'; + sdkSelect.appendChild(allOpt); + for (const pair of pairs || []) { + const opt = document.createElement('option'); + opt.value = String(pair.pair); + opt.textContent = `SDK Pair ${pair.pair} - PS5: ${pair.ps5Ver} / PS4: ${pair.ps4Ver}`; + sdkSelect.appendChild(opt); + } + if (currentValue && [...sdkSelect.options].some(o => o.value === String(currentValue))) { + sdkSelect.value = String(currentValue); + } +} + +function pickReadyBpgenPair(pairs, preferredPair = '') { + const ready = (pairs || []).filter(p => p.hasLibs).sort((a, b) => a.pair - b.pair); + if (!ready.length) return null; + return ready.find(p => String(p.pair) === String(preferredPair)) || ready[0]; +} + +async function hydrateBpgenCard() { + const sdkSelect = $('bpgen-sdk-pair'); + if (!sdkSelect) return; + + const [settings, sdkPairs, scan] = await Promise.all([ + window.pork.getSettings().catch(() => ({})), + window.pork.bpgenSdkPairs().catch(() => []), + window.pork.bpgenFakelibScan().catch(() => ({ baseDir: '', pairs: [] })), + ]); + + const savedPair = settings.bpgenSdkPair || ''; + renderBpgenSdkOptions(sdkPairs, savedPair); + + $('bpgen-output-dir').value = settings.bpgenOutputDir || ''; + $('bpgen-fl-basedir').value = scan.baseDir || settings.bpgenFakelibBaseDir || ''; + $('bpgen-fakelib-dir').value = settings.bpgenFakelibDir || ''; + $('bpgen-fw-label').value = settings.bpgenFirmwareLabel || ''; + + const activePair = pickReadyBpgenPair(scan.pairs, savedPair); + if (savedPair === 'all') { + sdkSelect.value = 'all'; + $('bpgen-fw-label').value = 'All'; + } else if (savedPair && [...sdkSelect.options].some(o => o.value === String(savedPair))) { + sdkSelect.value = String(savedPair); + } else if (activePair) { + // Prefer a ready fakelib pair for the initial UI state, but do not + // overwrite persisted settings just because the page was opened. + sdkSelect.value = String(activePair.pair); + if (!$('bpgen-fw-label').value.trim()) $('bpgen-fw-label').value = `${activePair.pair}.xx`; + if (!$('bpgen-fakelib-dir').value.trim()) $('bpgen-fakelib-dir').value = activePair.dir || ''; + } +} + +function ensureBpgenPageInit() { + if (_bpgenPageInitPromise) return _bpgenPageInitPromise; + _bpgenPageInitPromise = hydrateBpgenCard().catch(err => { + console.error('[backporks] bpgen hydrate failed', err); + }); + return _bpgenPageInitPromise; +} + +async function loadBackporks() { + ensureBpgenPageInit(); + const folders = await window.pork.backporksList(); + try { + const summaries = await window.pork.hashGameSummary(); + _populateHashMaps(summaries); + } catch (_) {} + const list = $('backpork-list'); + const empty = $('backpork-empty'); + + list.innerHTML = '
Loading firmware folders…
'; + empty.style.display = folders.length ? 'none' : 'block'; + if (!folders.length) { list.innerHTML = ''; return; } + + const gamesByFolder = await Promise.all( + folders.map(async f => ({ folder: f, games: await window.pork.backporksGames(f.name) })) + ); + + list.innerHTML = ''; + + for (const { folder: f, games } of gamesByFolder) { + + const card = document.createElement('div'); + card.className = 'backpork-card'; + + const gamesHtml = games.length ? games.map(g => { + const title = g.prospero_name || g.title || g.game_id; + const porked = g.porked_firmware === f.name; + const _bhs = _backporkHashSummary.get(g.game_id + '|' + f.name) || _gameHashSummary.get(g.game_id); + const hashPill = _bhs + ? _bhs.community_mismatch + ? `⚠ Hash Fail` + : _bhs.community_matches > 0 + ? `✓ Verified` + : `⧭ Hashed` + : ''; + return ` +
+ ${g.prospero_icon_url + ? `` + : '
'} +
+
${escHtml(title)}
+
${escHtml(g.game_id)}
+
+ ${porked ? `✓ Porked` : ''} + ${hashPill} +
+ + +
+
`; + }).join('') : '
No games detected — click Rescan
'; + + card.innerHTML = ` +
+
${escHtml(f.name)}
+
+
${escHtml(f.path)}
+
${games.length} game(s) detected
+
+
+ + + +
+
+
${gamesHtml}
`; + + list.appendChild(card); + } + + list.onclick = async e => { + const btn = e.target.closest('[data-action]'); + if (btn) { + const id = Number(btn.dataset.id); + if (btn.dataset.action === 'scan') { + btn.disabled = true; btn.textContent = 'Scanning…'; + try { + const { count } = await window.pork.backporksScan(id); + setStatus(`Rescan complete — ${count} game(s) found`, 'ok'); + await loadBackporks(); + await refreshFirmwareFilters(); + } catch (err) { setStatus(err.message, 'error'); } + } + if (btn.dataset.action === 'remove') { + const fwName = btn.closest('.backpork-card')?.querySelector('.backpork-badge')?.textContent || String(id); + const confirmation = await showExactConfirmation(`Remove firmware folder "${fwName}" from Chimera?`, 'VERWIJDER'); + if (!confirmation) return; + await window.pork.backporksRemove(id, confirmation); + setStatus('Folder removed', 'ok'); + await loadBackporks(); + await refreshFirmwareFilters(); + } + + if (btn.dataset.action === 'pork-all') { + const folderName = btn.dataset.fw; + const folderPath = btn.dataset.path; + const folderEntry = gamesByFolder.find(({ folder }) => folder.name === folderName); + const folderGames = folderEntry?.games || []; + + if (!folderGames.length) { setStatus('No games in this firmware folder', 'warn'); return; } + if (!await showConfirm(`Queue all ${folderGames.length} game(s) from ${folderName} for PS5 install?\n\nProgress will be shown below in real-time.`)) return; + + // ── Progress panel helpers ──────────────────────────────────────────── + const panel = $('pork-all-progress-panel'); + const queueFill = $('pork-all-queue-fill'); + const queueLbl = $('pork-all-queue-label'); + const queueCnt = $('pork-all-queue-count'); + const gameLbl = $('pork-all-current-game'); + const fileFill = $('pork-all-file-fill'); + const fileLbl = $('pork-all-file-label'); + const logEl = $('pork-all-log'); + + panel.style.display = ''; + logEl.textContent = ''; + panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + + const ts = () => new Date().toLocaleTimeString(); + function appendLog(line, color) { + const span = document.createElement('span'); + if (color) span.style.color = color; + span.textContent = `[${ts()}] ${line}\n`; + logEl.appendChild(span); + logEl.scrollTop = logEl.scrollHeight; + } + + function fmtBytes(b) { + if (b >= 1073741824) return (b / 1073741824).toFixed(2) + ' GB'; + if (b >= 1048576) return (b / 1048576).toFixed(1) + ' MB'; + if (b >= 1024) return (b / 1024).toFixed(0) + ' KB'; + return b + ' B'; + } + function fmtSpeed(bps) { + return fmtBytes(bps) + '/s'; + } + + // ── Phase 1: Resolve FTP paths (before registering listener) ──────────── + btn.disabled = true; + btn.textContent = 'Resolving paths…'; + appendLog(`Starting bulk apply — ${folderGames.length} game(s) from ${folderName}`); + queueLbl.textContent = `Resolving FTP paths… (0 / ${folderGames.length})`; + + const readyGames = []; // { game, ftpDest } + let skipped = 0, failed = 0; + let pathsDone = 0; + + for (const game of folderGames) { + try { + const ftpDest = await ensureGameFtpPath(game.game_id); + pathsDone++; + if (!ftpDest) { + skipped++; + appendLog(` SKIP ${game.game_id} — no FTP path (cancelled)`, '#f59e0b'); + } else { + appendLog(` PATH ${game.game_id} → ${ftpDest}`); + readyGames.push({ game, ftpDest }); + } + queueLbl.textContent = `Resolving FTP paths… (${pathsDone} / ${folderGames.length})`; + } catch (err) { + pathsDone++; + failed++; + appendLog(` ERROR ${game.game_id}: ${err.message}`, '#f87171'); + console.error('[pork-all]', game.game_id, err.message); + } + } + + if (!readyGames.length) { + const parts = [`Nothing queued from ${folderName}`]; + if (skipped) parts.push(`${skipped} skipped`); + if (failed) parts.push(`${failed} failed`); + appendLog(`\n${parts.join(' — ')}`, '#f59e0b'); + setStatus(parts.join(' — '), 'warn'); + panel.style.display = 'none'; + btn.disabled = false; + btn.textContent = `\u{1F527} Apply All (${folderGames.length})`; + return; + } + + // ── Phase 2: Register listener BEFORE queuing so we catch all events ── + const batchIds = new Set(); + const gameById = {}; + const lastState = {}; + let doneCount = 0; + let lastLoggedFile = {}; + let batchDone = false; + + const updateProgress = (transferState) => { + if (batchDone) return; + const batchJobs2 = (transferState.jobs || []).filter(j => batchIds.has(j.id)); + + let nowDone = 0; + let activeJob = null; + + for (const j of batchJobs2) { + const game = gameById[j.id]; + const prev = lastState[j.id]; + + if (j.status === 'active') activeJob = j; + + if (j.status !== prev) { + lastState[j.id] = j.status; + if (j.status === 'active') { + appendLog(`\n▶ START #${j.id} ${game?.game_id || j.gameId} (${j.label})`); + } else if (j.status === 'done') { + nowDone++; + appendLog(`✓ DONE #${j.id} ${game?.game_id || j.gameId}`, '#4ade80'); + } else if (j.status === 'error') { + nowDone++; + appendLog(`✗ ERROR #${j.id} ${game?.game_id || j.gameId}: ${j.error || 'unknown error'}`, '#f87171'); + } else if (j.status === 'cancelled') { + nowDone++; + appendLog(`⊘ CANCEL #${j.id} ${game?.game_id || j.gameId}`, '#f59e0b'); + } + } else if (j.status === 'done' || j.status === 'error' || j.status === 'cancelled') { + nowDone++; + } + + if (j.status === 'active' && j.progress?.file && j.progress.file !== lastLoggedFile[j.id]) { + lastLoggedFile[j.id] = j.progress.file; + const fileShort = j.progress.file.replace(/\\/g, '/').split('/').pop(); + const fileInfo = j.progress.filesTotal + ? ` FILE [${j.progress.filesDone + 1}/${j.progress.filesTotal}] ${fileShort}` + : ` FILE ${fileShort}`; + appendLog(fileInfo); + } + } + + doneCount = nowDone; + + // Overall queue bar — use batchIds.size (grows as jobs are queued) + const batchTotal = batchIds.size; + const qPct = batchTotal > 0 ? Math.round((doneCount / batchTotal) * 100) : 0; + if (!batchDone) { + // Only update bar/label while we're still monitoring + queueFill.style.width = qPct + '%'; + queueLbl.textContent = `Applying backporks to PS5 — ${doneCount} / ${batchTotal} complete`; + queueCnt.textContent = `${qPct}%`; + btn.textContent = `Applying… (${doneCount}/${batchTotal})`; + } + + if (activeJob) { + const g = gameById[activeJob.id]; + const p = activeJob.progress || {}; + const pct = p.percent || 0; + fileFill.style.width = pct + '%'; + + const fileShort = (p.file || '').replace(/\\/g, '/').split('/').pop(); + const speedStr = p.speedBps ? ` @ ${fmtSpeed(p.speedBps)}` : ''; + const sizeStr = p.total ? ` — ${fmtBytes(p.transferred)} / ${fmtBytes(p.total)}` : ''; + const filesStr = p.filesTotal ? ` [file ${p.filesDone + 1}/${p.filesTotal}]` : ''; + + gameLbl.textContent = `${g?.game_id || activeJob.gameId}${filesStr}: ${fileShort || 'preparing…'}`; + fileLbl.textContent = `${pct.toFixed(1)}%${sizeStr}${speedStr}`; + } else if (doneCount < batchTotal) { + gameLbl.textContent = 'Waiting for next job to start…'; + fileLbl.textContent = ''; + fileFill.style.width = '0%'; + } + + // All batch jobs finished (or no more in queue) + if (batchIds.size > 0 && doneCount >= batchIds.size) { + batchDone = true; + const errCount = batchJobs2.filter(j => j.status === 'error').length; + const cancelCount = batchJobs2.filter(j => j.status === 'cancelled').length; + const parts = [`Applied ${batchIds.size - errCount - cancelCount} of ${batchIds.size}`]; + if (skipped) parts.push(`${skipped} skipped`); + if (failed) parts.push(`${failed} path errors`); + if (errCount) parts.push(`${errCount} transfer errors`); + appendLog(`\n━━━ All done — ${parts.join(', ')} ━━━`, '#4ade80'); + queueFill.style.width = '100%'; + queueLbl.textContent = `Bulk apply complete — ${parts.join(', ')}`; + queueCnt.textContent = '100%'; + gameLbl.textContent = ''; + fileLbl.textContent = ''; + fileFill.style.width = '0%'; + btn.disabled = false; + btn.textContent = `\u{1F527} Apply All (${folderGames.length})`; + setStatus(parts.join(' — '), errCount || failed ? 'warn' : 'ok'); + loadBackporks().catch(() => {}); + _bpApplyUpdater = null; // stop forwarding transfer:update to this run + } + }; + + _bpApplyUpdater = updateProgress; + + // ── Phase 3: Queue jobs now that listener is active ─────────────────── + appendLog(`\nQueuing ${readyGames.length} job(s)…`); + queueLbl.textContent = `Queuing ${readyGames.length} job(s)…`; + btn.textContent = `Queuing… (0/${readyGames.length})`; + let queuedCount = 0; + + for (const { game } of readyGames) { + try { + const result = await window.pork.backporksPork(game.game_id, folderName, folderPath); + batchIds.add(result.jobId); + gameById[result.jobId] = game; + queuedCount++; + appendLog(` QUEUE #${result.jobId} ${game.game_id}`); + btn.textContent = `Queuing… (${queuedCount}/${readyGames.length})`; + } catch (err) { + failed++; + appendLog(` QUEUE-ERR ${game.game_id}: ${err.message}`, '#f87171'); + console.error('[pork-all queue]', game.game_id, err.message); + } + } + + if (!batchIds.size) { + batchDone = true; + _bpApplyUpdater = null; // nothing queued — stop forwarding + appendLog('\nNothing was queued — all queue attempts failed', '#f59e0b'); + setStatus('Queue failed — check Transfers tab', 'error'); + panel.style.display = 'none'; + btn.disabled = false; + btn.textContent = `\u{1F527} Apply All (${folderGames.length})`; + return; + } + + appendLog(`\nAll ${batchIds.size} job(s) queued — monitoring transfers…\n`); + queueLbl.textContent = `Applying backporks to PS5 — 0 / ${batchIds.size} complete`; + queueFill.style.width = '0%'; + btn.textContent = `Applying… (0/${batchIds.size})`; + return; + } + + return; + } + + const viewBtn = e.target.closest('[data-bp-view]'); + if (viewBtn) { openModal(viewBtn.dataset.bpView); return; } + + const porkBtn = e.target.closest('[data-bp-pork]'); + if (porkBtn && !porkBtn.disabled) { + const gameId = porkBtn.dataset.bpPork; + const folderName = porkBtn.dataset.bpFw; + const folderPath = porkBtn.dataset.bpPath; + + const ftpDest = await ensureGameFtpPath(gameId); + if (!ftpDest) return; // user cancelled picker + + porkBtn.disabled = true; + porkBtn.textContent = 'Queuing…'; + setStatus(`Queuing ${gameId} (${folderName}) for install…`); + try { + await window.pork.backporksPork(gameId, folderName, folderPath); + setStatus(`${gameId} queued — see Transfers tab for progress`, 'ok'); + await loadBackporks(); + } catch (err) { + setStatus(`Pork failed: ${err.message}`, 'error'); + porkBtn.disabled = false; + porkBtn.textContent = 'Pork ▶'; + } + } + }; +} + +window.pork.on('backporks:scan:complete', async ({ folders, count, uniqueGames }) => { + if (count > 0) { + const folderLabel = `${folders} firmware folder${folders !== 1 ? 's' : ''}`; + const buildLabel = `${count} build${count !== 1 ? 's' : ''}`; + const uniqueLabel = Number.isFinite(uniqueGames) && uniqueGames > 0 + ? ` across ${uniqueGames} unique game${uniqueGames !== 1 ? 's' : ''}` + : ''; + showToast(`Backporks: ${buildLabel} in ${folderLabel}${uniqueLabel}`, 'info'); + } + if (document.querySelector('.page.active')?.id === 'page-backporks') await loadBackporks(); + await refreshFirmwareFilters(); +}); + +$('btn-create-fw-folder').addEventListener('click', () => { + $('new-fw-form').hidden = !$('new-fw-form').hidden; + if (!$('new-fw-form').hidden) $('new-fw-name').focus(); +}); +$('btn-new-fw-cancel').addEventListener('click', () => { + $('new-fw-form').hidden = true; + $('new-fw-name').value = ''; + $('new-fw-parent').value = ''; +}); +$('btn-new-fw-browse').addEventListener('click', async () => { + const folder = await window.pork.selectFolder(); + if (folder) $('new-fw-parent').value = folder; +}); +$('btn-new-fw-create').addEventListener('click', async () => { + const name = $('new-fw-name').value.trim(); + const parentPath = $('new-fw-parent').value.trim(); + if (!name) { setStatus('Enter a firmware version (e.g. 11.50.0)', 'error'); return; } + if (!parentPath) { setStatus('Select a parent folder', 'error'); return; } + if (!/^\d+\.\d+\.\d+$/.test(name)) { setStatus('Version must follow x.x.x format (e.g. 11.50.0)', 'error'); return; } + try { + $('btn-new-fw-create').disabled = true; + const { path: created } = await window.pork.backporksCreateFolder(name, parentPath); + showToast(`Created firmware folder ${name}`, 'ok'); + $('new-fw-form').hidden = true; + $('new-fw-name').value = ''; + $('new-fw-parent').value = ''; + await loadBackporks(); + await refreshFirmwareFilters(); + } catch (err) { + setStatus(err.message, 'error'); + } finally { + $('btn-new-fw-create').disabled = false; + } +}); + +$('btn-add-backpork').addEventListener('click', async () => { + const folder = await window.pork.selectFolder(); + if (!folder) return; + + $('btn-add-backpork').disabled = true; + setStatus('Scanning firmware folders…'); + try { + const { folders, count } = await window.pork.backporksAddRoot(folder); + setStatus(`Found ${folders} firmware folder(s) — ${count} game(s) detected`, 'ok'); + showToast(`Registered ${folders} firmware folder(s)`, 'ok'); + await loadBackporks(); + await refreshFirmwareFilters(); + } catch (err) { + setStatus(err.message, 'error'); + } finally { + $('btn-add-backpork').disabled = false; + } +}); + +// Auto-Backporks card browse actions are wired here as a fallback because this +// page module is known-good: the main "Select Backpork Folder" button on the +// same page already uses the same picker bridge successfully. +function applyBpgenAutoConfigFromPairs(pairs) { + if (!Array.isArray(pairs) || !pairs.length) return; + const ready = pairs.filter(p => p.hasLibs).sort((a, b) => a.pair - b.pair); + if (!ready.length) return; + + const pair = ready[0].pair; + const sdkSelect = $('bpgen-sdk-pair'); + const fwInput = $('bpgen-fw-label'); + const fakelibInput = $('bpgen-fakelib-dir'); + + if (sdkSelect && [...sdkSelect.options].some(o => Number(o.value) === pair)) { + sdkSelect.value = String(pair); + } + if (fwInput) fwInput.value = `${pair}.xx`; + if (fakelibInput && !fakelibInput.value.trim()) { + fakelibInput.value = ready[0].dir || ''; + } + window.pork.setSettings({ + bpgenSdkPair: String(pair), + bpgenFirmwareLabel: `${pair}.xx`, + bpgenFakelibDir: fakelibInput?.value.trim() || '', + }).catch(() => {}); +} + +if (!_bpgenPageInitStarted) { + _bpgenPageInitStarted = true; + + $('bpgen-sdk-pair')?.addEventListener('change', async () => { + const pair = $('bpgen-sdk-pair').value; + const baseDir = $('bpgen-fl-basedir').value.trim().replace(/[\\\/]+$/, ''); + if (pair === 'all') { + $('bpgen-fw-label').value = 'All'; + if (baseDir) $('bpgen-fakelib-dir').value = ''; + } else { + $('bpgen-fw-label').value = pair ? `${pair}.xx` : ''; + if (baseDir && pair) $('bpgen-fakelib-dir').value = `${baseDir}\\${pair}`; + } + await window.pork.setSettings({ + bpgenSdkPair: pair, + bpgenFirmwareLabel: $('bpgen-fw-label').value.trim(), + bpgenFakelibDir: $('bpgen-fakelib-dir').value.trim(), + }).catch(() => {}); + }); +} + +if (!$('bpgen-btn-output')?.dataset.boundInBackporks) { + $('bpgen-btn-output').dataset.boundInBackporks = '1'; + $('bpgen-btn-output').addEventListener('click', async e => { + e.preventDefault(); + e.stopPropagation(); + try { + const folder = await window.pork.selectFolder(); + if (folder) { + $('bpgen-output-dir').value = folder; + await window.pork.setSettings({ bpgenOutputDir: folder }); + } + } catch (err) { + setStatus(`Output folder browse failed: ${err.message}`, 'error'); + } + }); +} + +if (!$('bpgen-btn-fakelib')?.dataset.boundInBackporks) { + $('bpgen-btn-fakelib').dataset.boundInBackporks = '1'; + $('bpgen-btn-fakelib').addEventListener('click', async e => { + e.preventDefault(); + e.stopPropagation(); + try { + const folder = await window.pork.selectFolder(); + if (folder) { + $('bpgen-fakelib-dir').value = folder; + const save = { bpgenFakelibDir: folder }; + const pairMatch = String(folder).match(/[\\\/](\d+)\s*$/); + if (pairMatch) { + const pair = Number(pairMatch[1]); + if (Number.isFinite(pair)) { + if ([...$('bpgen-sdk-pair').options].some(o => Number(o.value) === pair)) { + $('bpgen-sdk-pair').value = String(pair); + } + $('bpgen-fw-label').value = `${pair}.xx`; + save.bpgenSdkPair = String(pair); + save.bpgenFirmwareLabel = `${pair}.xx`; + } + } + await window.pork.setSettings(save); + } + } catch (err) { + setStatus(`Fakelib folder browse failed: ${err.message}`, 'error'); + } + }); +} + +if (!$('bpgen-fl-btn-base')?.dataset.boundInBackporks) { + $('bpgen-fl-btn-base').dataset.boundInBackporks = '1'; + $('bpgen-fl-btn-base').addEventListener('click', async e => { + e.preventDefault(); + e.stopPropagation(); + try { + const folder = await window.pork.selectFolder(); + if (!folder) return; + $('bpgen-fl-basedir').value = folder; + await window.pork.setSettings({ bpgenFakelibBaseDir: folder }); + if (window.pork.bpgenFakelibSetBasedir) { + const result = await window.pork.bpgenFakelibSetBasedir(folder); + applyBpgenAutoConfigFromPairs(result?.pairs || []); + } + } catch (err) { + setStatus(`Fakelib base dir browse failed: ${err.message}`, 'error'); + } + }); +} diff --git a/renderer/modules/backups.js b/renderer/modules/backups.js new file mode 100644 index 0000000..6c348cb --- /dev/null +++ b/renderer/modules/backups.js @@ -0,0 +1,161 @@ +// ── Backups page ────────────────────────────────────────────────────────────── +function protectionText(key, fallback) { + if (typeof window.t !== 'function') return fallback; + const translated = window.t(key); + return translated && translated !== key ? translated : fallback; +} + +async function loadBackups() { + try { + const gameContext = consumeGameWorkspaceContext('backups'); + if (gameContext && $('backups-search')) $('backups-search').value = gameContext; + const titles = []; + let offset = 0; + let total = 0; + do { + const packet = await window.pork.listLibraryTitles({ limit: 200, offset }); + const page = window.chimeraLibraryModel.items(packet); + titles.push(...page); + total = Number(packet && packet.total || page.length); + offset += page.length; + if (!page.length) break; + } while (offset < total); + state.backups = window.chimeraProtectionModel.flatten(titles); + populateBackupsRegionFilter(); + renderBackups(); + } catch (e) { + setStatus(e.message, 'error'); + } +} + +function populateBackupsRegionFilter() { + const sel = $('backups-region-filter'); + const cur = sel.value; + const regions = [...new Set( + state.backups.map(copy => copy.region || '').filter(Boolean).sort() + )]; + sel.innerHTML = '' + + regions.map(r => ``).join(''); +} + +function renderBackups() { + const search = ($('backups-search')?.value || '').toLowerCase(); + const region = $('backups-region-filter')?.value || ''; + const list = $('backups-list'); + const empty = $('backups-empty'); + + const filtered = state.backups.filter(copy => { + if (region && copy.region !== region) return false; + if (search) { + const haystack = [copy.titleName, copy.titleId, copy.deviceName, copy.path, copy.hash].filter(Boolean).join(' ').toLowerCase(); + if (!haystack.includes(search)) return false; + } + return true; + }); + + empty.hidden = filtered.length > 0; + list.innerHTML = filtered.map(copy => { + const verified = copy.lastVerifiedAt ? new Date(copy.lastVerifiedAt).toLocaleString(window.i18n?.current || 'en') : protectionText('protection.copy.never', 'Never'); + const hash = copy.hash ? `${copy.hashAlgorithm.toUpperCase()} ${copy.hash}` : `${protectionText('protection.copy.notHashed', 'Not hashed')} (${copy.hashState})`; + const integrity = copy.missing + ? protectionText('protection.copy.missing', 'Missing copy') + : copy.integrityFailed + ? `${protectionText('protection.copy.failed', 'Failed')} (${copy.integrityState})` + : copy.integrityState === 'unknown' ? protectionText('protection.copy.notVerified', 'Not verified') : copy.integrityState; + const policyClass = copy.policy.healthy ? 'is-healthy' : copy.policy.status === 'needs-diversity' ? 'needs-diversity' : 'is-degraded'; + const policyLabel = copy.policy.healthy + ? `${copy.policy.distinctDeviceCount}/${copy.policy.requiredDistinctDevices} ${protectionText('protection.policy.devicesProtected', 'devices protected')}` + : `${copy.policy.distinctDeviceCount}/${copy.policy.requiredDistinctDevices} ${protectionText('protection.policy.distinctDevices', 'distinct devices')}`; + return `
+
+
+
${escHtml(copy.titleName)}${escHtml(copy.titleId)} · copy ${escHtml(copy.copyId)}
+
+
+
${escHtml(protectionText('protection.copy.device', 'Device'))}
+
${escHtml(protectionText('protection.copy.path', 'Path'))}${escHtml(copy.path)}
+
${escHtml(protectionText('protection.copy.hash', 'Hash'))}${escHtml(hash)}
+
${escHtml(protectionText('protection.copy.lastVerified', 'Last verified'))}${escHtml(verified)}
+
${escHtml(protectionText('protection.copy.integrity', 'Integrity'))}${escHtml(integrity)}
+
${escHtml(protectionText('protection.copy.size', 'Size'))}${escHtml(copy.sizeBytes ? fmt(copy.sizeBytes) : protectionText('protection.copy.unknown', 'Unknown'))}
+
+
+ ${escHtml(policyLabel)} + + +
+
`; + }).join(''); + + list.onclick = async e => { + const labelButton = e.target.closest('[data-label-device-id]'); + if (labelButton) { + const label = await showPrompt(protectionText('protection.copy.renamePrompt', 'Friendly name for this drive:'), labelButton.dataset.deviceLabel || ''); + if (label == null) return; + await window.pork.labelStorageDevice({ deviceId: Number(labelButton.dataset.labelDeviceId), label }); + await loadBackups(); + setStatus(protectionText('protection.copy.renamed', 'Drive label saved.'), 'ok'); + return; + } + const restore = e.target.closest('[data-restore-copy-id]'); + if (restore) { + const copy = state.backups.find(item => item.copyId === restore.dataset.restoreCopyId); + if (!copy) return; + state.protectionRestoreTarget = copy.restoreTarget; + list.querySelectorAll('[data-restore-copy-id]').forEach(button => { + button.setAttribute('aria-pressed', String(button === restore)); + if (!button.disabled) button.textContent = button === restore + ? protectionText('protection.copy.restoreSelected', 'Restore source selected') + : protectionText('protection.copy.prepareRestore', 'Prepare restore'); + }); + setStatus(`${protectionText('protection.copy.restoreSelected', 'Restore source selected')}: ${copy.copyId} · ${copy.deviceName}. ${protectionText('protection.copy.noMutation', 'No restore has started.')}`, 'ok'); + return; + } + const details = e.target.closest('[data-view-game]'); + if (details) openModal(details.dataset.viewGame); + }; +} + +$('backups-search')?.addEventListener('input', renderBackups); +$('backups-region-filter')?.addEventListener('change', renderBackups); + +$('btn-scan-local').addEventListener('click', async () => { + $('btn-scan-local').disabled = true; + setStatus('Scanning local game source folders…'); + try { + const { total, added } = await window.pork.scanBackups(); + await loadBackups(); + await loadDashboard(); + const msg = added > 0 + ? `Found ${total} game(s) — ${added} new, fetching metadata…` + : `Scan complete: ${total} game(s) found, none new`; + setStatus(msg, 'ok'); + } catch (e) { + setStatus(e.message, 'error'); + } finally { + $('btn-scan-local').disabled = false; + } +}); + +$('btn-rescan-all-backporks').addEventListener('click', async () => { + const btn = $('btn-rescan-all-backporks'); + btn.disabled = true; + const orig = btn.textContent; + btn.textContent = 'Scanning…'; + setStatus('Rescanning all backpork folders…'); + try { + const { folders, count, uniqueGames } = await window.pork.backporksScanAll(); + await loadBackporks(); + await refreshFirmwareFilters(); + const uniqueLabel = Number.isFinite(uniqueGames) && uniqueGames > 0 + ? ` across ${uniqueGames} unique game(s)` + : ''; + setStatus(`Rescan complete — ${count} build(s) in ${folders} firmware folder(s)${uniqueLabel}`, 'ok'); + } catch (e) { + setStatus(e.message, 'error'); + } finally { + btn.disabled = false; + btn.textContent = orig; + } +}); + diff --git a/renderer/modules/bdjb-generator.js b/renderer/modules/bdjb-generator.js new file mode 100644 index 0000000..f480fa2 --- /dev/null +++ b/renderer/modules/bdjb-generator.js @@ -0,0 +1,60 @@ +// BDJB Genny + +const bdjbPayloads = []; +const bdjbEntries = []; + +function renderBdjbEntries() { + const list = $('bdjbgen-list'); + if (!bdjbEntries.length) { list.innerHTML = '

No entries yet. Add payload files, then adjust their autoload order and delays.

'; return; } + list.innerHTML = bdjbEntries.map((entry, index) => `
${entry.type === 'delay' ? `Delay: ${entry.value} ms` : `Payload: ${escHtml(entry.value)}`}
`).join(''); +} + +function bdjbLog(message) { + const log = $('bdjbgen-log'); + log.textContent += `${message}\n`; + log.scrollTop = log.scrollHeight; +} + +$('btn-bdjbgen-add-payloads').addEventListener('click', async () => { + const added = await window.pork.bdjbGenSelectPayloads(); + for (const payload of added) { + if (bdjbPayloads.some((existing) => existing.name === payload.name)) { bdjbLog(`Skipped duplicate filename: ${payload.name}`); continue; } + bdjbPayloads.push(payload); bdjbEntries.push({ type: 'payload', value: payload.name }); + } + renderBdjbEntries(); +}); + +$('btn-bdjbgen-add-delay').addEventListener('click', async () => { + const value = await showPrompt('Delay in milliseconds:', '1000'); + if (value === null || value === '') return; + bdjbEntries.push({ type: 'delay', value: String(value).trim() }); renderBdjbEntries(); +}); + +$('bdjbgen-list').addEventListener('click', (event) => { + const button = event.target.closest('button'); if (!button) return; + const remove = button.dataset.bdjbRemove, up = button.dataset.bdjbUp, down = button.dataset.bdjbDown; + if (remove !== undefined) bdjbEntries.splice(Number(remove), 1); + else if (up !== undefined) { const i = Number(up); [bdjbEntries[i - 1], bdjbEntries[i]] = [bdjbEntries[i], bdjbEntries[i - 1]]; } + else if (down !== undefined) { const i = Number(down); [bdjbEntries[i + 1], bdjbEntries[i]] = [bdjbEntries[i], bdjbEntries[i + 1]]; } + renderBdjbEntries(); +}); + +window.pork.on('bdjbgen:progress', ({ stage, message, percent }) => { + $('bdjbgen-progress').value = percent || 0; + $('bdjbgen-status').textContent = `${stage}: ${message}`; + bdjbLog(`[${stage}] ${message}`); +}); + +$('btn-bdjbgen-build').addEventListener('click', async () => { + const button = $('btn-bdjbgen-build'); + button.disabled = true; $('bdjbgen-log').textContent = ''; $('bdjbgen-progress').value = 0; + try { + const result = await window.pork.bdjbGenBuild({ entries: bdjbEntries, payloads: bdjbPayloads, discTitle: $('bdjbgen-title').value }); + if (result.canceled) { $('bdjbgen-status').textContent = 'Build canceled.'; return; } + $('bdjbgen-status').textContent = `ISO ready: ${result.outputIso} (${result.bytes} bytes)`; + bdjbLog(`Validated ISO with ${result.payloadCount} payload(s).`); + } catch (error) { + $('bdjbgen-status').textContent = `Build failed: ${error.message}`; + bdjbLog(`ERROR: ${error.message}`); + } finally { button.disabled = false; } +}); diff --git a/renderer/modules/cheats.js b/renderer/modules/cheats.js new file mode 100644 index 0000000..4f39131 --- /dev/null +++ b/renderer/modules/cheats.js @@ -0,0 +1,496 @@ +// ── Cheats page ─────────────────────────────────────────────────────────────── + +let _cheatsDownloading = false; + +function fmtEta(sec) { + if (!sec || sec <= 0) return '—'; + const m = Math.floor(sec / 60); + const s = Math.floor(sec % 60); + return m > 0 ? `${m}m ${s}s` : `${s}s`; +} + +let _cheatsSearchTimer = null; + +// Map of cusa_id → prospero_icon_url, built once per loadCheats() call +let _cheatIconMap = new Map(); +const _cheatIconAttempted = new Set(); + +async function refreshCheatStats() { + try { + const stats = await window.pork.cheatsStats(); + $('cheats-stat-files').textContent = `${stats.files ?? 0} files cached`; + $('cheats-stat-games').textContent = `covering ${stats.games ?? 0} games`; + const badge = $('nav-cheats-badge'); + if (stats.files > 0) { + badge.textContent = stats.files; + badge.hidden = false; + } else { + badge.hidden = true; + } + } catch (_) {} +} + +async function loadCheats({ background = false } = {}) { + // Hide the progress panel unless a download is actively running + if (!_cheatsDownloading) { + $('cheats-dl-panel').hidden = true; + $('btn-cheats-download-all').disabled = false; + } + + await refreshCheatStats(); + // Background warming keeps the badge current without building thousands of + // hidden cards or starting artwork requests for a page the user did not open. + if (background) return; + + // Build icon map from games — reuse state.games if already loaded, otherwise fetch + try { + const games = state.games.length ? state.games : await window.pork.listGames({}); + _cheatIconMap = new Map(games.map(g => [g.game_id, g.prospero_icon_url || ''])); + } catch (_) {} + + // Render with current search + const q = $('cheats-search').value.trim(); + await renderCheatResults(q); + + // Unmatched section (only shown when not searching) + if (!q) await renderUnmatchedCheats(); + else $('cheats-unmatched-section').hidden = true; +} + +// ── GarlicSaves ────────────────────────────────────────────────────────────── +let _garlicBusy = false; +let _garlicHooked = false; + +async function loadGarlic() { + if (!_garlicHooked) { + _garlicHooked = true; + + $('btn-garlic-site').addEventListener('click', e => { + e.preventDefault(); + window.pork.openShell('https://garlicsaves.com'); + }); + $('garlic-contribute-link').addEventListener('click', e => { + e.preventDefault(); + window.pork.openShell('https://garlicsaves.com/contribute'); + }); + + // Start Working — show confirm modal + $('btn-garlic-start').addEventListener('click', () => { + if (_garlicBusy) return; + openAccessibleDialog($('garlic-confirm-overlay'), $('btn-garlic-confirm-cancel')); + }); + + // Modal: Cancel + $('btn-garlic-confirm-cancel').addEventListener('click', () => { + closeAccessibleDialog($('garlic-confirm-overlay')); + }); + + // Modal: Confirm — run the worker + $('btn-garlic-confirm-ok').addEventListener('click', async () => { + closeAccessibleDialog($('garlic-confirm-overlay')); + if (_garlicBusy) return; + _garlicBusy = true; + $('btn-garlic-start').disabled = true; + $('garlic-progress-area').hidden = false; + $('garlic-progress-text').textContent = 'Starting…'; + $('garlic-progress-bar').style.width = '0%'; + try { + await window.pork.garlicStart(); + $('garlic-status-badge').hidden = false; + showToast('GarlicSaves worker started! Restart PS5 to stop.', 'success'); + } catch (e) { + $('garlic-progress-text').textContent = 'Error: ' + e.message; + showToast('Failed to start worker: ' + e.message, 'error'); + } finally { + _garlicBusy = false; + $('btn-garlic-start').disabled = false; + } + }); + + // Live progress events + window.pork.on('garlic:progress', ({ msg, percent }) => { + const txt = $('garlic-progress-text'); + const bar = $('garlic-progress-bar'); + if (txt) txt.textContent = msg; + if (bar && percent != null) bar.style.width = Math.min(100, percent) + '%'; + }); + } +} + +function _garlicShowProgress(show) { + $('garlic-progress-area').hidden = !show; + if (show) { + $('garlic-progress-text').textContent = 'Starting…'; + $('garlic-progress-bar').style.width = '0%'; + } +} + + +let _lazyIconCancel = false; + +// Fetch icons from prosperopatches.com for cheat entries not already in the icon map. +// Throttled at one request per 500 ms, capped at 100 entries, cancellable. +async function lazyFetchCheatIcons(cusaIds) { + _lazyIconCancel = true; // cancel any prior run + await new Promise(r => setTimeout(r, 20)); // let prior loop see the flag + _lazyIconCancel = false; + + const toFetch = [...new Set(cusaIds)] + .filter(id => /^(?:CUSA|PPSA)\d{5}$/i.test(String(id || '')) && !_cheatIconAttempted.has(id)) + .slice(0, 40); + for (let i = 0; i < toFetch.length; i++) { + if (_lazyIconCancel) break; + const id = toFetch[i]; + _cheatIconAttempted.add(id); + try { + const { iconUrl } = await window.pork.prosperoFetchIcon(id); + if (_lazyIconCancel) break; + if (iconUrl) { + _cheatIconMap.set(id, iconUrl); + // Update every visible card for this game + document.querySelectorAll(`.cheat-card[data-cusa="${id}"]`).forEach(card => { + const empty = card.querySelector('.cheat-card-icon-empty'); + if (!empty) return; + const img = document.createElement('img'); + img.className = 'cheat-card-icon'; + img.alt = ''; + img.src = iconUrl; + img.onerror = () => { img.style.display = 'none'; }; + empty.replaceWith(img); + }); + } + } catch (_) {} + if (!_lazyIconCancel && i < toFetch.length - 1) await new Promise(r => setTimeout(r, 500)); + } +} + +async function renderCheatResults(query) { + const list = $('cheats-list'); + const empty = $('cheats-empty'); + list.innerHTML = '
Loading…
'; + empty.hidden = true; + + try { + // Both cheatsAll() and cheatsSearch() return flat rows: { cusa_id, title, file_count } + const rows = query + ? await window.pork.cheatsSearch(query) + : await window.pork.cheatsAll(); + + list.innerHTML = ''; + if (!rows || rows.length === 0) { + empty.hidden = false; + return; + } + + for (const row of rows) { + const card = document.createElement('div'); + card.className = 'cheat-card'; + card.dataset.cusa = row.cusa_id; + const fc = row.file_count ?? 1; + const iconUrl = _cheatIconMap.get(row.cusa_id) || ''; + const iconHtml = iconUrl + ? `` + : `
`; + card.innerHTML = ` +
+ ${iconHtml} +
+
+ ${escHtml(row.cusa_id)} + ${escHtml(row.title || row.cusa_id)} +
+ ${fc} version${fc !== 1 ? 's' : ''} cached +
+
`; + list.appendChild(card); + } + + // Background-fetch icons for entries that have no icon in the map + const missing = rows.filter(r => r.cusa_id && !_cheatIconMap.get(r.cusa_id)).map(r => r.cusa_id); + if (missing.length > 0) lazyFetchCheatIcons(missing); + } catch (e) { + list.innerHTML = `
${escHtml(e.message)}
`; + } +} + +// Renders per-version file block inside modal cheat panel (has full data field) +function renderCheatFileBlock(f) { + let parsed; + try { parsed = typeof f.data === 'string' ? JSON.parse(f.data) : f.data; } catch (_) { parsed = null; } + const mods = parsed?.mods ?? []; + const modsHtml = mods.map(m => ` +
+ ${escHtml(m.name || '?')} + ${escHtml(m.type || '')} +
`).join(''); + + return ` +
+
v${escHtml(f.version || '?')} ${escHtml(f.filename || '')}
+
${modsHtml || 'No mods parsed'}
+
`; +} + +async function renderUnmatchedCheats() { + const section = $('cheats-unmatched-section'); + const list = $('cheats-unmatched-list'); + const badge = $('cheats-unmatched-count'); + + try { + const rows = await window.pork.cheatsUnmatched(); + if (!rows || rows.length === 0) { + section.hidden = true; + return; + } + + section.hidden = false; + badge.textContent = rows.length; + + list.innerHTML = rows.map(r => ` +
+
+ ${escHtml(r.title || r.filename)} + ${escHtml(r.filename)} +
+
+ + +
+
`).join(''); + } catch (e) { + section.hidden = false; + list.innerHTML = `
${escHtml(e.message)}
`; + } +} + +// Assign CUSA ID delegation +$('cheats-unmatched-list').addEventListener('click', async e => { + const btn = e.target.closest('.btn-assign-cusa'); + if (!btn) return; + const row = btn.closest('.cheat-unmatched-row'); + const filename = row?.dataset.filename; + const input = row?.querySelector('.unmatched-cusa-input'); + const cusaId = input?.value.trim().toUpperCase(); + + if (!filename || !cusaId || !/^[A-Z]{4}\d{5}$/.test(cusaId)) { + input?.focus(); + input?.classList.add('input-error'); + setTimeout(() => input?.classList.remove('input-error'), 1200); + return; + } + + btn.disabled = true; + btn.textContent = '…'; + try { + await window.pork.cheatsAssign(filename, cusaId); + row.style.opacity = '0'; + row.style.transition = 'opacity .3s'; + setTimeout(() => renderUnmatchedCheats(), 320); + // Refresh stats / matched list + loadCheats(); + } catch (err) { + btn.disabled = false; + btn.textContent = 'Assign'; + setStatus(`Assign failed: ${err.message}`, 'error'); + } +}); + +// Search input (debounced) +$('cheats-search').addEventListener('input', () => { + clearTimeout(_cheatsSearchTimer); + _cheatsSearchTimer = setTimeout(() => { + const q = $('cheats-search').value.trim(); + renderCheatResults(q); + if (!q) renderUnmatchedCheats(); + else $('cheats-unmatched-section').hidden = true; + }, 350); +}); + +// Download-all +$('btn-cheats-download-all').addEventListener('click', async () => { + _cheatsDownloading = true; + $('cheats-dl-panel').hidden = false; + $('cheats-dl-title').textContent = 'Fetching cheat index…'; + $('cheats-dl-bar').style.width = '0%'; + $('cheats-dl-count').textContent = '0 / 0'; + $('cheats-dl-pct').textContent = '0%'; + $('cheats-dl-saved').textContent = 'Saved: 0'; + $('cheats-dl-failed').textContent = 'Failed: 0'; + $('cheats-dl-eta').textContent = 'ETA: —'; + $('cheats-dl-current').textContent = ''; + $('btn-cheats-download-all').disabled = true; + try { + await window.pork.cheatsDownloadAll(); + } catch (e) { + _cheatsDownloading = false; + $('cheats-dl-title').textContent = `Error: ${e.message}`; + $('btn-cheats-download-all').disabled = false; + } +}); + +// Cancel download +$('btn-cheats-dl-cancel').addEventListener('click', async () => { + await window.pork.cheatsCancel(); + $('cheats-dl-title').textContent = 'Cancelling…'; +}); + +// Progress IPC events +window.pork.on('cheats:dl:start', ({ total }) => { + $('cheats-dl-title').textContent = `Downloading cheat database…`; + $('cheats-dl-count').textContent = `0 / ${total}`; + $('cheats-dl-bar').style.width = '0%'; +}); + +window.pork.on('cheats:dl:progress', ({ done, total, saved, failed, etaSec, current, pct }) => { + $('cheats-dl-bar').style.width = `${pct}%`; + $('cheats-dl-count').textContent = `${done} / ${total}`; + $('cheats-dl-pct').textContent = `${pct}%`; + $('cheats-dl-saved').textContent = `Saved: ${saved}`; + $('cheats-dl-failed').textContent = `Failed: ${failed}`; + $('cheats-dl-eta').textContent = `ETA: ${fmtEta(etaSec)}`; + $('cheats-dl-current').textContent = current || ''; +}); + +window.pork.on('cheats:dl:done', ({ saved, failed, cancelled }) => { + _cheatsDownloading = false; + $('cheats-dl-bar').style.width = cancelled ? $('cheats-dl-bar').style.width : '100%'; + $('cheats-dl-title').textContent = cancelled + ? `Download cancelled — ${saved} saved, ${failed} failed` + : `Done — ${saved} saved, ${failed} failed`; + $('cheats-dl-pct').textContent = cancelled ? '' : '100%'; + $('cheats-dl-eta').textContent = ''; + $('cheats-dl-current').textContent = ''; + $('btn-cheats-download-all').disabled = false; + // Scheduled server syncs also emit this event. Keep hidden-page work cheap; + // the full list and its artwork hydrate only when Cheats is actually open. + const active = $('page-cheats').classList.contains('active'); + loadCheats({ background: !active }); +}); + +// ── Cheats FTP install ───────────────────────────────────────────────────────── + +$('btn-cheats-ftp-install').addEventListener('click', async () => { + // Reset panel to instruction state + $('cheats-ftp-progress-wrap').hidden = true; + $('cheats-ftp-bar').style.width = '0%'; + $('cheats-ftp-phase').textContent = 'Preparing…'; + $('cheats-ftp-count').textContent = ''; + $('cheats-ftp-pct').textContent = '0%'; + $('cheats-ftp-current').textContent = ''; + $('cheats-ftp-title').textContent = 'Install Cheats via FTP'; + $('btn-cheats-ftp-go').disabled = false; + $('btn-cheats-ftp-go').textContent = '↑ Install Now'; + // Populate path from settings + const s = await window.pork.getSettings(); + const configuredPath = (s.cheatsRemotePath || '').trim(); + $('cheats-ftp-path').value = configuredPath || '/data/etaHEN/cheats/'; + $('cheats-ftp-no-path-warn').hidden = !!configuredPath; + $('cheats-ftp-panel').hidden = false; +}); + +$('btn-cheats-ftp-browse').addEventListener('click', async () => { + const cur = $('cheats-ftp-path').value.trim() || '/'; + const p = await openFtpBrowser(cur); + if (p) $('cheats-ftp-path').value = p; +}); + +$('btn-cheats-ftp-close').addEventListener('click', () => { + $('cheats-ftp-panel').hidden = true; +}); + +$('btn-cheats-ftp-go').addEventListener('click', async () => { + $('btn-cheats-ftp-go').disabled = true; + $('btn-cheats-ftp-go').textContent = 'Installing…'; + $('cheats-ftp-progress-wrap').hidden = false; + try { + const remotePath = $('cheats-ftp-path').value.trim() || '/data/etaHEN/cheats/'; + await window.pork.cheatsFtpInstall(remotePath); + } catch (e) { + $('cheats-ftp-title').textContent = `Error: ${e.message}`; + $('btn-cheats-ftp-go').disabled = false; + $('btn-cheats-ftp-go').textContent = '↑ Retry'; + } +}); + +window.pork.on('cheats:ftp:start', ({ total }) => { + $('cheats-ftp-count').textContent = `0 / ${total}`; +}); + +window.pork.on('cheats:ftp:progress', ({ phase, done, total, pct, current }) => { + $('cheats-ftp-bar').style.width = `${pct}%`; + $('cheats-ftp-pct').textContent = `${pct}%`; + $('cheats-ftp-count').textContent = `${done} / ${total}`; + $('cheats-ftp-phase').textContent = phase === 'write' ? 'Writing files…' : 'Uploading to PS5…'; + $('cheats-ftp-current').textContent = current || ''; +}); + +window.pork.on('cheats:ftp:done', ({ count }) => { + $('cheats-ftp-bar').style.width = '100%'; + $('cheats-ftp-pct').textContent = '100%'; + $('cheats-ftp-phase').textContent = `Done — ${count} cheats installed`; + $('cheats-ftp-current').textContent = ''; + $('cheats-ftp-title').textContent = 'Install complete ✓'; + $('btn-cheats-ftp-go').disabled = false; + $('btn-cheats-ftp-go').textContent = '↑ Install Again'; +}); + +// ── Cheats modal panel ───────────────────────────────────────────────────────── + +async function renderCheatsPanel(game) { + const body = $('modal-cheats-body'); + const badge = $('mtab-cheats-count'); + body.innerHTML = '
Loading cheats…
'; + badge.textContent = ''; + + try { + const cusaId = game.game_id; + const files = await window.pork.cheatsForGame(cusaId); + + if (!files || files.length === 0) { + // Nothing cached — offer to fetch on-demand + body.innerHTML = ` + `; + $('btn-modal-fetch-cheats').addEventListener('click', async () => { + const fetchBtn = $('btn-modal-fetch-cheats'); + fetchBtn.disabled = true; + fetchBtn.textContent = 'Fetching…'; + try { + const index = await window.pork.cheatsFetchIndex(); + const matching = index.filter(e => e.cusaId === cusaId); + if (matching.length === 0) { + body.innerHTML = `
No cheats found in repository for ${escHtml(cusaId)}.
`; + return; + } + // Download the cheat database (cached, with progress on the Cheats page), + // then re-render this game's cheats from the freshly populated cache. + fetchBtn.textContent = `Downloading ${matching.length} file(s)…`; + await window.pork.cheatsDownloadAll(); + const cached = await window.pork.cheatsForGame(cusaId); + if (cached && cached.length) { + badge.textContent = cached.length; + body.innerHTML = cached.map(f => renderCheatFileBlock(f)).join(''); + } else { + body.innerHTML = `
Downloaded, but no cheats cached for ${escHtml(cusaId)}.
`; + } + } catch (e) { + body.innerHTML = `
Fetch failed: ${escHtml(e.message)}
`; + } + }); + return; + } + + badge.textContent = files.length; + const modCount = files.reduce((n, f) => { + try { const d = typeof f.data === 'string' ? JSON.parse(f.data) : f.data; return n + (d?.mods?.length ?? 0); } catch (_) { return n; } + }, 0); + + body.innerHTML = files.map(f => renderCheatFileBlock(f)).join(''); + badge.textContent = `${modCount}`; + } catch (e) { + body.innerHTML = `
${escHtml(e.message)}
`; + } +} + diff --git a/renderer/modules/compatibility-guidance.js b/renderer/modules/compatibility-guidance.js new file mode 100644 index 0000000..b72729a --- /dev/null +++ b/renderer/modules/compatibility-guidance.js @@ -0,0 +1,28 @@ +(function initCompatibilityGuidance(root, factory) { + const api = factory(); + if (typeof module === 'object' && module.exports) module.exports = api; + if (root) root.chimeraCompatibilityGuidance = api; +})(typeof window !== 'undefined' ? window : null, function compatibilityGuidanceFactory() { + 'use strict'; + const STATES = new Set(['compatible', 'incompatible', 'workaround', 'conflict', 'unknown']); + const esc = value => String(value ?? '').replace(/[&<>"']/g, character => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]); + const tr = (key, fallback) => typeof window !== 'undefined' && window.t?.(key) && window.t(key) !== key ? window.t(key) : fallback; + function normalize(value = {}) { const state = STATES.has(value.state) ? value.state : 'unknown'; return { state, blocking: value.blocking === true, rationale: Array.isArray(value.rationale) ? value.rationale : [], workarounds: Array.isArray(value.workarounds) ? value.workarounds : [] }; } + function markup(value) { + const guidance = normalize(value); + const labels = { compatible: 'Compatible', incompatible: 'Incompatible', workaround: 'Workaround available', conflict: 'Conflicting evidence', unknown: 'Compatibility unknown' }; + const rationale = guidance.rationale.map(item => `
  • ${esc(item.explanation)}${esc(item.providerId)} / ${esc(item.revision)} / ${esc(item.confidence)} confidence${esc(tr('compatibilityGuidance.inspectSource', 'Inspect source'))}
  • `).join(''); + const workarounds = guidance.workarounds.map(item => `
  • ${esc(item.explanation)} ${esc(item.providerId)} / ${esc(item.revision)}
  • `).join(''); + const detail = guidance.state === 'unknown' ? tr('compatibilityGuidance.unknownDetail', 'No applicable evidence is cached for this exact context.') : guidance.state === 'conflict' ? tr('compatibilityGuidance.conflictDetail', 'Sources disagree. Chimera does not resolve this automatically.') : guidance.blocking ? tr('compatibilityGuidance.blockingDetail', 'Blocked by critical, high-confidence, non-conflicting evidence.') : tr('compatibilityGuidance.informationalDetail', 'Guidance is informational unless the evidence meets the blocking policy.'); + return `
    ${esc(tr(`compatibilityGuidance.state.${guidance.state}`, labels[guidance.state]))}${guidance.blocking ? `${esc(tr('compatibilityGuidance.blocked', 'Blocked'))}` : ''}

    ${esc(detail)}

    ${rationale ? `
      ${rationale}
    ` : ''}${workarounds ? `
    ${esc(tr('compatibilityGuidance.workarounds', 'Safe workaround guidance'))}
      ${workarounds}
    ` : ''}
    `; + } + async function load(host, context) { + if (!host || typeof window === 'undefined') return null; + let guidance; + try { guidance = await window.pork.compatibilityGuidance(context); } catch (_) { guidance = { state: 'unknown', unknownReason: 'unavailable' }; } + host.querySelector('.compatibility-guidance')?.remove(); + host.insertAdjacentHTML('beforeend', markup(guidance)); + return guidance; + } + return Object.freeze({ normalize, markup, load }); +}); diff --git a/renderer/modules/connection.js b/renderer/modules/connection.js new file mode 100644 index 0000000..9a4bbe2 --- /dev/null +++ b/renderer/modules/connection.js @@ -0,0 +1,268 @@ +// ── Connection state ────────────────────────────────────────────────────────── +function setConnected(on) { + state.connected = on; + const dot = $('conn-dot'); + const label = $('conn-label'); + const badge = $('dash-conn-badge'); + const shellHealth = $('chimera-shell-health'); + const shellHealthLabel = $('chimera-shell-health-label'); + const tr = (key, fallback) => typeof window.t === 'function' ? window.t(key, fallback) : fallback; + + dot.className = `dot ${on ? 'green' : 'red'}`; + label.textContent = on ? tr('connection.connected', 'Connected') : tr('connection.disconnected', 'Disconnected'); + badge.textContent = on ? tr('connection.online', 'Online') : tr('connection.offline', 'Offline'); + badge.className = `badge ${on ? 'badge-green' : 'badge-red'}`; + if (shellHealth) { + shellHealth.classList.toggle('is-online', on); + shellHealth.classList.toggle('is-offline', !on); + } + if (shellHealthLabel) { + shellHealthLabel.textContent = on + ? tr('connection.ready', 'Ready') + : tr('connection.disconnected', 'Disconnected'); + shellHealthLabel.dataset.i18n = on ? 'connection.ready' : 'connection.disconnected'; + } + + $('btn-connect').disabled = on; + $('btn-disconnect').disabled = !on; + $('btn-scan').disabled = !on; + + // Re-evaluate PS5 action buttons — only enabled when connected AND a location is stored + const deleteBtn = $('btn-manage-delete-ps5'); + const backupBtn = $('btn-manage-backup-ps5'); + if (deleteBtn) deleteBtn.disabled = !on || !state.currentGame?.ftp_path; + if (backupBtn) backupBtn.disabled = !on || !state.currentGame?.ftp_path; +} + +// ── Dashboard ───────────────────────────────────────────────────────────────── +let lastHomeReadiness = null; + +async function loadDashboard() { + try { + const [settings, stats, catalog, ftpStatus] = await Promise.all([ + window.pork.getSettings(), + window.pork.getStats(), + window.pork.gameCatalogHealth().catch(() => ({})), + window.pork.ftpStatus().catch(() => ({ connected: state.connected })), + ]); + $('s-total').textContent = stats.totalGames; + $('s-installed').textContent = stats.installed; + $('s-backedup').textContent = stats.backedUp; + $('s-backups').textContent = stats.totalBackups; + if ($('d-host') && document.activeElement !== $('d-host') && !$('d-host').value) { + $('d-host').value = settings.ftpHost || ''; + } + if ($('d-ports') && document.activeElement !== $('d-ports') && !$('d-ports').value) { + $('d-ports').value = (settings.ftpPorts && settings.ftpPorts.length) ? settings.ftpPorts.join(', ') : '1337, 2121, 21'; + } + renderHomeReadiness({ settings, stats, catalog, connected: Boolean(ftpStatus.connected) }); + } catch (_) {} + renderPinnedTiles(); +} + +function renderHomeReadiness({ settings = {}, stats, catalog, connected }) { + lastHomeReadiness = { settings, stats, catalog, connected }; + const tr = (key, fallback) => typeof window.t === 'function' ? window.t(key, fallback) : fallback; + const setIndicator = (id, stateName) => { + const indicator = $(id); + if (indicator) indicator.className = `chimera-readiness-dot ${stateName}`; + }; + const complete = Number(catalog.complete || 0); + const missing = Number(catalog.missing || catalog.incomplete || 0); + const total = Number(stats.totalGames || 0); + const backedUp = Number(stats.backedUp || 0); + const host = String(settings.ftpHost || '').trim(); + const hostConfigured = Boolean(host); + const consoleStatus = $('home-console-status'); + const libraryStatus = $('home-library-status'); + const backupStatus = $('home-backup-status'); + const inspectorState = $('home-inspector-state'); + const inspectorReason = $('home-inspector-reason'); + const inspectorTarget = $('home-inspector-target'); + const libraryTruth = $('home-truth-library'); + const protectionTruth = $('home-truth-protection'); + if (consoleStatus) consoleStatus.textContent = connected + ? tr('home.consoleReady', 'Connected · manual operations') + : hostConfigured + ? tr('home.targetConfiguredOffline', 'Configured · offline') + : tr('navigation.unavailable', 'Not configured'); + if (libraryStatus) libraryStatus.textContent = total + ? `${complete}/${total} ${tr('home.titlesEnriched', 'titles fully enriched')}${missing ? ` · ${missing} ${tr('home.toComplete', 'to complete')}` : ''}` + : tr('home.noGames', 'No games detected yet'); + if (backupStatus) backupStatus.textContent = total + ? `${backedUp}/${total} ${tr('home.gamesProtected', 'games protected')}` + : `${Number(stats.totalBackups || 0)} ${tr('home.backupFiles', 'backup files')}`; + if (inspectorState) { + inspectorState.textContent = connected + ? tr('connection.ready', 'Ready') + : hostConfigured + ? tr('home.targetOffline', 'Offline') + : tr('navigation.unavailable', 'Not configured'); + inspectorState.className = `chimera-node-state ${connected ? 'ok' : hostConfigured ? 'attention' : 'unknown'}`; + } + if (inspectorReason) inspectorReason.textContent = connected + ? tr('home.targetReasonConnected', 'A bounded FTP session with the PS5 is active.') + : hostConfigured + ? tr('home.targetReasonOffline', 'The PS5 target is saved, but there is no active FTP session.') + : tr('home.targetReasonMissing', 'Chimera needs an explicit PS5 target before console features can connect.'); + if (inspectorTarget) inspectorTarget.textContent = hostConfigured ? host : tr('navigation.unavailable', 'Not configured'); + if (libraryTruth) libraryTruth.textContent = total + ? `${complete}/${total} ${tr('home.titlesEnriched', 'titles fully enriched')}` + : tr('home.noGames', 'No games detected yet'); + if (protectionTruth) protectionTruth.textContent = total + ? `${backedUp}/${total} ${tr('home.gamesProtected', 'games protected')}` + : `${Number(stats.totalBackups || 0)} ${tr('home.backupFiles', 'backup files')}`; + setIndicator('home-console-indicator', connected ? 'ok' : hostConfigured ? 'attention' : 'unknown'); + setIndicator('home-library-indicator', total && missing === 0 ? 'ok' : total ? 'attention' : 'unknown'); + setIndicator('home-backup-indicator', total && backedUp >= total ? 'ok' : backedUp ? 'attention' : 'unknown'); + + const title = $('home-next-title'); + const detail = $('home-next-detail'); + const action = $('home-next-action'); + if (!title || !detail || !action) return; + if (!hostConfigured) { + title.textContent = tr('home.next.configureTitle', 'Configure the PS5 target'); + detail.textContent = tr('home.next.configureDetail', 'Choose the console target yourself. Chimera will not guess an address or start a connection automatically.'); + action.textContent = tr('home.next.configureAction', 'Configure PS5 target'); + action.dataset.route = 'settings/general'; + action.dataset.focusTarget = 's-host'; + } else if (!connected) { + title.textContent = tr('home.next.connectTitle', 'Connect safely to the PS5 first'); + detail.textContent = tr('home.next.connectDetail', 'The target is configured. Review it and start the bounded FTP connection manually.'); + action.textContent = tr('home.next.connectAction', 'Review connection'); + action.dataset.route = 'home'; + action.dataset.focusTarget = 'd-host'; + } else if (!total) { + title.textContent = tr('home.next.scanTitle', 'Detect your game library'); + detail.textContent = tr('home.next.scanDetail', 'The PS5 is reachable, but the Chimera catalog does not contain any games yet.'); + action.textContent = tr('home.next.scanAction', 'Scan games'); + action.dataset.route = 'library/games'; + delete action.dataset.focusTarget; + } else if (missing > 0) { + title.textContent = `${tr('home.next.completeMetadata', 'Complete metadata for')} ${missing} ${missing === 1 ? tr('home.game', 'game') : tr('home.games', 'games')}`; + detail.textContent = tr('home.next.metadataDetail', 'Titles, cover art and version information are enriched automatically; review only persistent exceptions.'); + action.textContent = tr('home.next.openLibrary', 'Open library'); + action.dataset.route = 'library/games'; + delete action.dataset.focusTarget; + } else if (backedUp < total) { + const remaining = total - backedUp; + title.textContent = `${remaining} ${remaining === 1 ? tr('home.game', 'game') : tr('home.games', 'games')} ${tr('home.next.notProtected', 'not protected yet')}`; + detail.textContent = tr('home.next.backupDetail', 'Review backups and create a new copy only from a confirmed source.'); + action.textContent = tr('home.next.checkBackups', 'Check backups'); + action.dataset.route = 'protection/backups'; + delete action.dataset.focusTarget; + } else { + title.textContent = tr('home.next.readyTitle', 'Chimera is ready'); + detail.textContent = tr('home.next.readyDetail', 'Console, metadata and library protection are in order. Updates remain explicit and reviewable.'); + action.textContent = tr('home.next.reviewPayloads', 'Review payload updates'); + action.dataset.route = 'payloads/updates'; + delete action.dataset.focusTarget; + } +} + +window.addEventListener('chimera:language-changed', () => { + if (lastHomeReadiness) renderHomeReadiness(lastHomeReadiness); +}); + +async function renderPinnedTiles() { + const grid = $('dash-pinned-grid'); + const empty = $('dash-pinned-empty'); + if (!grid) return; + try { + const s = await window.pork.getSettings(); + const pinned = new Set(s.pinnedCards || []); + const cards = PINNABLE_CARDS.filter(c => pinned.has(c.id)); + + grid.innerHTML = ''; + if (!cards.length) { + empty.hidden = false; + } else { + empty.hidden = true; + for (const c of cards) { + const tile = document.createElement('div'); + tile.className = 'dash-pinned-tile'; + tile.dataset.navPage = c.page; + if (c.type === 'widget') { + tile.classList.add('dash-pinned-tile--widget'); + tile.removeAttribute('data-nav-page'); + tile.innerHTML = _renderWidgetTile(c); + grid.appendChild(tile); + _initWidgetTile(c, tile); + continue; // already appended + } else { + tile.innerHTML = ` +
    ${escHtml(c.label)}
    +
    ${escHtml(_PIN_PAGE_LABELS[c.page] || c.page)}
    +
    ${escHtml(c.hint)}
    + `; + } + grid.appendChild(tile); + } + } + + // Sync all pin button visual states across the whole page + document.querySelectorAll('.btn-pin').forEach(btn => { + btn.classList.toggle('pinned', pinned.has(btn.dataset.pinId)); + }); + } catch (_) {} +} + +// ── FTP Connect ─────────────────────────────────────────────────────────────── +$('btn-connect').addEventListener('click', async () => { + const host = $('d-host').value.trim(); + const ports = $('d-ports').value.split(',').map(p => p.trim()).filter(Boolean); + if (!ports.length) ports.push('21'); + if (!host) { setStatus('Enter a host address', 'error'); return; } + + // Credential fields start blank. Fall back to stored settings when blank + // so the user can click Connect without re-typing every session. + const saved = await window.pork.getSettings(); + const user = $('d-user').value.trim() || saved.ftpUser || ''; + const password = $('d-pass').value || saved.ftpPass || ''; + + $('btn-connect').disabled = true; + setStatus('Connecting…'); + try { + const result = await window.pork.ftpConnect({ host, ports, user, password }); + setConnected(true); + setStatus('Connected', 'ok'); + $('sb-sync').textContent = `Connected on :${result?.port || ports[0]} at ${new Date().toLocaleTimeString()}`; + } catch (e) { + setConnected(false); + setStatus(`Connection failed: ${e.message}`, 'error'); + $('btn-connect').disabled = false; + } +}); + +$('btn-disconnect').addEventListener('click', async () => { + await window.pork.ftpDisconnect(); + setConnected(false); + setStatus('Disconnected'); +}); + +// ── FTP Scan ────────────────────────────────────────────────────────────────── +$('btn-scan').addEventListener('click', async () => { + const prog = $('scan-progress'); + prog.hidden = false; + prog.textContent = 'Scanning PS5 game folders…'; + $('btn-scan').disabled = true; + + window.pork.on('ftp:progress', ({ current, game }) => { + prog.textContent = `[${current}] Found: ${game.title || game.game_id}`; + }); + + try { + const { count } = await window.pork.ftpScan(); + prog.textContent = `Scan complete — ${count} game(s) found.`; + $('sb-sync').textContent = `Last sync: ${new Date().toLocaleTimeString()}`; + setStatus(`Scanned ${count} games`, 'ok'); + await loadDashboard(); + } catch (e) { + prog.textContent = `Error: ${e.message}`; + setStatus(e.message, 'error'); + } finally { + window.pork.off('ftp:progress'); + $('btn-scan').disabled = false; + } +}); + diff --git a/renderer/modules/dashboard-widgets.js b/renderer/modules/dashboard-widgets.js new file mode 100644 index 0000000..7cfd909 --- /dev/null +++ b/renderer/modules/dashboard-widgets.js @@ -0,0 +1,317 @@ +'use strict'; + +// ── Dashboard Interactive Mini-Widgets ──────────────────────────────────────── +// Renders and drives interactive tool tiles pinned to the dashboard. +// Called from connection.js renderPinnedTiles() for cards with type:'widget'. + +// Off-screen canvas used by the XAvatar widget for pixel conversion +const _dwXaCanvas = document.createElement('canvas'); +let _dwXaFilename = 'avatar'; + +// ── Render ───────────────────────────────────────────────────────────────────── +// Returns an innerHTML string for a given pinnable card. +function _renderWidgetTile(card) { + switch (card.id) { + case 'psnotify': + return ` +
    📢 Send Notification
    + + +
    + + +
    +
    + +
    `; + + case 'payload-mgr': + return ` +
    ⇨ Quick Payload Send
    + +
    + Port + + + +
    +
    + +
    `; + + case 'xavatar-convert': + return ` +
    👤 XAvatar Quick Send
    +
    Drop image or click to browse
    +
    + + + +
    +
    + +
    `; + + case 'dash-savemgr': + return ` +
    💾 Save Manager
    +
    Click Reload to fetch saves.
    +
    + + + +
    `; + + default: + // Fallback: plain nav tile for unknown widget IDs + return ` +
    ${escHtml(card.label)}
    +
    ${escHtml(card.hint)}
    + `; + } +} + +// ── Post-render init ─────────────────────────────────────────────────────────── +// Called immediately after the tile is appended to the DOM. +function _initWidgetTile(card, tileEl) { + switch (card.id) { + case 'payload-mgr': + _dwPopulatePayloadSelect(tileEl); + break; + case 'xavatar-convert': + _dwWireXavatarDrop(tileEl); + break; + default: + break; + } +} + +// ── Payload select population ────────────────────────────────────────────────── +async function _dwPopulatePayloadSelect(tileEl) { + const sel = tileEl.querySelector('[data-dw-key="payload-file"]'); + if (!sel) return; + try { + const files = await window.pork.payloadListLocal(); + if (!files.length) { + sel.innerHTML = ''; + return; + } + sel.innerHTML = files.map(f => + `` + ).join(''); + } catch (_) { + sel.innerHTML = ''; + } +} + +// ── XAvatar drop-zone wiring ─────────────────────────────────────────────────── +function _dwWireXavatarDrop(tileEl) { + const drop = tileEl.querySelector('[data-dw-drop="xavatar"]'); + if (!drop) return; + + drop.addEventListener('click', () => { + const inp = document.createElement('input'); + inp.type = 'file'; + inp.accept = 'image/*'; + inp.onchange = e => { + const file = e.target.files[0]; + if (file) _dwLoadXavatarFile(file, tileEl); + }; + inp.click(); + }); + + drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('dw-drag-over'); }); + drop.addEventListener('dragleave', () => drop.classList.remove('dw-drag-over')); + drop.addEventListener('drop', e => { + e.preventDefault(); + drop.classList.remove('dw-drag-over'); + const file = e.dataTransfer?.files[0]; + if (file && file.type.startsWith('image/')) _dwLoadXavatarFile(file, tileEl); + }); +} + +async function _dwLoadXavatarFile(file, tileEl) { + const status = tileEl.querySelector('[data-dw-status="xavatar"]'); + const sendBtn = tileEl.querySelector('[data-dw-action="xavatar-send"]'); + const drop = tileEl.querySelector('[data-dw-drop="xavatar"]'); + if (status) status.textContent = 'Loading…'; + try { + const bmp = await createImageBitmap(file); + _dwXaCanvas.width = bmp.width; + _dwXaCanvas.height = bmp.height; + _dwXaCanvas.getContext('2d').drawImage(bmp, 0, 0); + _dwXaFilename = file.name.replace(/\.[^.]+$/, ''); + if (drop) drop.textContent = file.name; + if (sendBtn) sendBtn.disabled = false; + if (status) status.textContent = 'Ready'; + } catch (err) { + if (status) status.textContent = 'Load failed'; + } +} + +// ── Save Manager ─────────────────────────────────────────────────────────────── +async function _dwSavemgrLoad(btn) { + const tile = btn.closest('.dash-pinned-tile'); + const listEl = tile?.querySelector('[data-dw-list="savemgr"]'); + const status = tile?.querySelector('[data-dw-status="savemgr"]'); + if (!listEl) return; + btn.disabled = true; + btn.textContent = 'Loading…'; + if (status) status.textContent = ''; + try { + const r = await window.pork.savemgrRequest('/api/saves'); + const saves = typeof r === 'string' ? JSON.parse(r) : r; + if (!saves || !saves.length) { + listEl.textContent = 'No saves found.'; + if (status) status.textContent = '0 saves'; + } else { + listEl.innerHTML = saves.slice(0, 8).map((s, i) => + `
    + ${escHtml(s.name || s.title || s.titleId || `Save ${i}`)} + +
    ` + ).join(''); + if (status) status.textContent = `${saves.length} save${saves.length !== 1 ? 's' : ''}`; + } + } catch (err) { + listEl.textContent = 'Error: ' + err.message; + } finally { + btn.disabled = false; + btn.textContent = '↻ Reload'; + } +} + +async function _dwSavemgrDl(btn) { + const idx = btn.dataset.smIdx; + const tile = btn.closest('.dash-pinned-tile'); + const status = tile?.querySelector('[data-dw-status="savemgr"]'); + btn.disabled = true; + btn.textContent = '…'; + if (status) status.textContent = 'Mounting…'; + try { + await window.pork.savemgrRequest('/api/mount?idx=' + idx); + if (status) status.textContent = 'Downloading…'; + await window.pork.savemgrDownload('/api/download', `save_${idx}.zip`); + if (status) status.textContent = '✓ Saved'; + setTimeout(() => { if (status) status.textContent = ''; }, 4000); + } catch (err) { + if (status) status.textContent = 'Error: ' + err.message; + } finally { + btn.disabled = false; + btn.textContent = '⬇ ZIP'; + } +} + +// ── Event delegation ─────────────────────────────────────────────────────────── +document.addEventListener('click', async e => { + // "Open →" buttons inside widget tiles — navigate to the full page + const navBtn = e.target.closest('.dash-pinned-tile-open[data-nav-page]'); + if (navBtn && navBtn.closest('.dash-pinned-tile--widget')) { + e.stopPropagation(); + navigate(navBtn.dataset.navPage); + return; + } + + const btn = e.target.closest('.dash-widget-btn[data-dw-action]'); + if (!btn) return; + e.stopPropagation(); + + switch (btn.dataset.dwAction) { + case 'psnotify-send': await _dwPsnotifySend(btn); break; + case 'payload-send': await _dwPayloadSend(btn); break; + case 'xavatar-browse': _dwXavatarBrowseClick(btn); break; + case 'xavatar-send': await _dwXavatarSend(btn); break; + case 'savemgr-reload': await _dwSavemgrLoad(btn); break; + case 'savemgr-dl': await _dwSavemgrDl(btn); break; + } +}); + +// ── PSNotify action ──────────────────────────────────────────────────────────── +async function _dwPsnotifySend(btn) { + const tile = btn.closest('.dash-pinned-tile'); + const msg = tile?.querySelector('[data-dw-key="psnotify-msg"]')?.value?.trim() || ''; + const sub = tile?.querySelector('[data-dw-key="psnotify-sub"]')?.value?.trim() || ''; + const status = tile?.querySelector('[data-dw-status="psnotify"]'); + if (!msg) { if (status) { status.textContent = 'Message required'; status.style.color = 'var(--red)'; } return; } + btn.disabled = true; + if (status) { status.textContent = 'Sending…'; status.style.color = ''; } + try { + await window.pork.psnotifySend(msg, sub); + if (status) { status.textContent = '✓ Sent'; status.style.color = 'var(--green)'; } + if (tile) { + const msgEl = tile.querySelector('[data-dw-key="psnotify-msg"]'); + const subEl = tile.querySelector('[data-dw-key="psnotify-sub"]'); + if (msgEl) msgEl.value = ''; + if (subEl) subEl.value = ''; + } + setTimeout(() => { if (status) { status.textContent = ''; status.style.color = ''; } }, 3000); + } catch (err) { + if (status) { status.textContent = 'Error: ' + err.message; status.style.color = 'var(--red)'; } + } finally { + btn.disabled = false; + } +} + +// ── Payload action ───────────────────────────────────────────────────────────── +async function _dwPayloadSend(btn) { + const tile = btn.closest('.dash-pinned-tile'); + const sel = tile?.querySelector('[data-dw-key="payload-file"]'); + const portEl = tile?.querySelector('[data-dw-key="payload-port"]'); + const status = tile?.querySelector('[data-dw-status="payload"]'); + const val = sel?.value || ''; + if (!val) { if (status) { status.textContent = 'Pick a payload'; status.style.color = 'var(--red)'; } return; } + const [localPath, filename] = val.split('|'); + const port = parseInt(portEl?.value || '9021', 10); + btn.disabled = true; + if (status) { status.textContent = 'Sending…'; status.style.color = ''; } + try { + await window.pork.payloadTcpSend({ localPath, port, filename }); + if (status) { status.textContent = `✓ ${filename} sent`; status.style.color = 'var(--green)'; } + setTimeout(() => { if (status) { status.textContent = ''; status.style.color = ''; } }, 4000); + } catch (err) { + if (status) { status.textContent = 'Error: ' + err.message; status.style.color = 'var(--red)'; } + } finally { + btn.disabled = false; + } +} + +// ── XAvatar actions ──────────────────────────────────────────────────────────── +function _dwXavatarBrowseClick(btn) { + const tile = btn.closest('.dash-pinned-tile'); + const inp = document.createElement('input'); + inp.type = 'file'; + inp.accept = 'image/*'; + inp.onchange = e => { + const file = e.target.files[0]; + if (file) _dwLoadXavatarFile(file, tile); + }; + inp.click(); +} + +async function _dwXavatarSend(btn) { + const tile = btn.closest('.dash-pinned-tile'); + const status = tile?.querySelector('[data-dw-status="xavatar"]'); + if (_dwXaCanvas.width === 0) return; + btn.disabled = true; + if (status) { status.textContent = 'Converting…'; status.style.color = ''; } + try { + const ctx = _dwXaCanvas.getContext('2d'); + const imgData = ctx.getImageData(0, 0, _dwXaCanvas.width, _dwXaCanvas.height); + const rgba = new Uint8Array(imgData.data.buffer); + const res = await window.pork.xavatarConvertCanvas(rgba, _dwXaCanvas.width, _dwXaCanvas.height, _dwXaFilename); + if (!res || !res.ok) throw new Error((res && res.error) || 'Conversion failed'); + if (status) status.textContent = 'Uploading…'; + const pad = n => String(n).padStart(5, '0'); + const rnd = pad(Math.floor(Math.random() * 100000)); + const name = `chimera${rnd}.xavatar`; + await window.pork.xavatarUploadToPs5(res.buffer?.buffer || res.buffer, name); + if (status) { status.textContent = `✓ Queued: ${name}`; status.style.color = 'var(--green)'; } + setTimeout(() => { if (status) { status.textContent = ''; status.style.color = ''; } }, 5000); + } catch (err) { + if (status) { status.textContent = 'Error: ' + err.message; status.style.color = 'var(--red)'; } + } finally { + btn.disabled = false; + } +} diff --git a/renderer/modules/database.js b/renderer/modules/database.js new file mode 100644 index 0000000..5bd4cb5 --- /dev/null +++ b/renderer/modules/database.js @@ -0,0 +1,521 @@ +// ── Database page ───────────────────────────────────────────────────────────── +const DB_PAGE_SIZE = 100; +const _dbPages = { + games: { offset: 0, total: 0 }, + backups: { offset: 0, total: 0 }, + hashes: { offset: 0, total: 0 }, +}; + +async function loadDatabase() { + await Promise.all([loadDbTable('games'), loadDbTable('backups'), loadHashRowsTable()]); +} + +async function loadHashRowsTable() { + try { + const [rows, countRows] = await Promise.all([ + window.pork.dbQuery(`SELECT id, game_id, hash_type, firmware_label, file_size, + SUBSTR(file_hash,1,16) || '…' AS file_hash_short, + backup_path, verified, hashed_at + FROM game_hashes ORDER BY hashed_at DESC LIMIT ${DB_PAGE_SIZE} OFFSET ${_dbPages.hashes.offset}`), + window.pork.dbQuery('SELECT COUNT(*) AS total FROM game_hashes'), + ]); + _dbPages.hashes.total = Number(countRows[0]?.total || 0); + if (_dbPages.hashes.offset >= _dbPages.hashes.total && _dbPages.hashes.offset > 0) { + _dbPages.hashes.offset = Math.max(0, Math.floor(Math.max(0, _dbPages.hashes.total - 1) / DB_PAGE_SIZE) * DB_PAGE_SIZE); + return loadHashRowsTable(); + } + const countEl = $('hash-rows-count'); + if (countEl) countEl.textContent = dbPageLabel('hashes'); + updateDbPager('hashes'); + renderDbTableEl('db-hash-rows-table', rows, 'hashes'); + } catch (e) { + setStatus(e.message, 'error'); + } +} + +async function loadDbTable(name) { + try { + const sql = name === 'games' + ? `SELECT id, title, game_id, version, size, installed, backed_up, last_scanned FROM games ORDER BY title LIMIT ${DB_PAGE_SIZE} OFFSET ${_dbPages[name].offset}` + : `SELECT id, game_id, backup_type, size, backup_path, created_at FROM backups ORDER BY created_at DESC LIMIT ${DB_PAGE_SIZE} OFFSET ${_dbPages[name].offset}`; + const [rows, countRows] = await Promise.all([window.pork.dbQuery(sql), window.pork.dbQuery(`SELECT COUNT(*) AS total FROM ${name}`)]); + _dbPages[name].total = Number(countRows[0]?.total || 0); + if (_dbPages[name].offset >= _dbPages[name].total && _dbPages[name].offset > 0) { + _dbPages[name].offset = Math.max(0, Math.floor(Math.max(0, _dbPages[name].total - 1) / DB_PAGE_SIZE) * DB_PAGE_SIZE); + return loadDbTable(name); + } + const info = $(`db-${name}-page-info`); + if (info) info.textContent = dbPageLabel(name); + updateDbPager(name); + renderDbTableEl(`db-${name}-table`, rows, name); + } catch (e) { + setStatus(e.message, 'error'); + } +} + +function dbPageLabel(name) { + const page = _dbPages[name]; + const first = page.total ? page.offset + 1 : 0; + const last = Math.min(page.total, page.offset + DB_PAGE_SIZE); + const label = name === 'games' ? 'games' : name === 'backups' ? 'back-ups' : 'hashes'; + const separator = window.i18n?.current === 'nl' ? 'van' : 'of'; + return `${first}–${last} ${separator} ${page.total} ${label}`; +} + +function dbColumnLabel(column) { + return String(column || '') + .replace(/_/g, ' ') + .replace(/\b\w/g, letter => letter.toUpperCase()) + .replace(/\bId\b/g, 'ID') + .replace(/\bSha 256\b/gi, 'SHA-256'); +} + +function dbFormatValue(column, value) { + if (value == null || value === '') return '—'; + if (['size', 'file_size'].includes(column) && Number.isFinite(Number(value))) return _fmtBytes(Number(value)); + if (['installed', 'backed_up', 'verified'].includes(column)) return Number(value) ? 'Yes' : 'No'; + if (/(?:_at|last_scanned)$/.test(column)) { + const numeric = Number(value); + const timestamp = Number.isFinite(numeric) && numeric > 0 && numeric < 1e12 ? numeric * 1000 : value; + const date = new Date(timestamp); + if (!Number.isNaN(date.getTime())) return date.toLocaleString(window.i18n?.current || 'en'); + } + return String(value); +} + +function updateDbPager(name) { + document.querySelectorAll(`.db-page-btn[data-table="${name}"]`).forEach(button => { + button.disabled = button.dataset.direction === 'previous' + ? _dbPages[name].offset === 0 + : _dbPages[name].offset + DB_PAGE_SIZE >= _dbPages[name].total; + }); +} + +document.querySelectorAll('.db-page-btn').forEach(button => button.addEventListener('click', async () => { + const name = button.dataset.table; + const direction = button.dataset.direction; + if (!_dbPages[name]) return; + _dbPages[name].offset = direction === 'previous' + ? Math.max(0, _dbPages[name].offset - DB_PAGE_SIZE) + : Math.min(Math.max(0, _dbPages[name].total - 1), _dbPages[name].offset + DB_PAGE_SIZE); + if (name === 'hashes') await loadHashRowsTable(); + else await loadDbTable(name); +})); + +function renderDbTableEl(tableId, rows, tableName) { + const table = $(tableId); + if (!table) return; + // Hide toolbar when table reloads + const toolbar = $(`toolbar-${tableName}`); + if (toolbar) toolbar.hidden = true; + + if (!rows.length) { + table.innerHTML = ''; + return; + } + const cols = Object.keys(rows[0]); + const hasId = cols.includes('id'); + table.innerHTML = ` + ${hasId ? '' : ''}${cols.map(c => `${escHtml(dbColumnLabel(c))}`).join('')} + + ${rows.map(r => { + const rowId = hasId ? r.id : ''; + return `${hasId ? `` : ''}${cols.map(c => `${escHtml(dbFormatValue(c, r[c]))}`).join('')}`; + }).join('')} + `; + + if (!hasId) return; + + // Wire up "select all" header checkbox + const selectAllCb = table.querySelector('.db-select-all'); + if (selectAllCb) { + selectAllCb.addEventListener('change', () => { + table.querySelectorAll('.db-row-check').forEach(cb => { + cb.checked = selectAllCb.checked; + cb.closest('tr').classList.toggle('db-row-selected', selectAllCb.checked); + }); + _updateRowToolbar(tableName); + }); + } + + // Wire up individual row checkboxes + table.querySelectorAll('.db-row-check').forEach(cb => { + cb.addEventListener('change', () => { + cb.closest('tr').classList.toggle('db-row-selected', cb.checked); + _updateRowToolbar(tableName); + }); + }); +} + +function _getCheckedIds(tableName) { + const tableEl = tableName === 'games' ? $('db-games-table') + : tableName === 'backups' ? $('db-backups-table') + : $('db-hash-rows-table'); + if (!tableEl) return []; + return [...tableEl.querySelectorAll('.db-row-check:checked')].map(cb => Number(cb.value)); +} + +function _updateRowToolbar(tableName) { + const ids = _getCheckedIds(tableName); + const toolbar = $(`toolbar-${tableName}`); + if (!toolbar) return; + toolbar.hidden = ids.length === 0; + const countEl = $(`toolbar-${tableName}-count`); + if (countEl) countEl.textContent = `${ids.length} selected`; +} + +// DB Tabs +document.querySelectorAll('.db-tab').forEach(tab => + tab.addEventListener('click', () => { + document.querySelectorAll('.db-tab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.db-panel').forEach(p => p.classList.remove('active')); + tab.classList.add('active'); + $(`dbtab-${tab.dataset.dbtab}`).classList.add('active'); + if (tab.dataset.dbtab === 'hash-rows') loadHashRowsTable(); + if (tab.dataset.dbtab === 'hashes') loadHashDbContent(); + }) +); + +// Query runner +$('btn-run-query').addEventListener('click', async () => { + const sql = $('query-input').value.trim(); + if (!sql) return; + const out = $('query-results'); + try { + const rows = await window.pork.dbQuery(sql); + if (!rows.length) { out.innerHTML = '
    Query returned no rows.
    '; return; } + const cols = Object.keys(rows[0]); + out.innerHTML = ` +
    + + ${cols.map(c => ``).join('')} + ${rows.map(r => `${cols.map(c => ``).join('')}`).join('')} +
    ${escHtml(c)}
    ${escHtml(r[c] ?? '')}
    +
    `; + } catch (e) { + out.innerHTML = `
    ${escHtml(e.message)}
    `; + } +}); + +// Exports +$('btn-export-sql').addEventListener('click', async () => { + const p = await window.pork.exportSql(); + if (p) setStatus(`Exported to ${p}`, 'ok'); +}); +$('btn-export-games-csv').addEventListener('click', async () => { + const p = await window.pork.exportCsv('games'); + if (p) setStatus(`Exported to ${p}`, 'ok'); +}); +$('btn-export-backups-csv').addEventListener('click', async () => { + const p = await window.pork.exportCsv('backups'); + if (p) setStatus(`Exported to ${p}`, 'ok'); +}); +$('btn-export-hashes').addEventListener('click', async () => { + const p = await window.pork.hashExport(); + if (p) setStatus(`Hash DB exported to ${p}`, 'ok'); +}); + +// ── Hash Database tab ─────────────────────────────────────────────────────────────────────── +async function loadHashDbContent() { + try { + const info = await window.pork.hashCommunityInfo(); + const countEl = $('hdb-community-count'); + const verEl = $('hdb-community-ver'); + const srcEl = $('hdb-community-source'); + const localEl = $('hdb-local-count'); + if (!countEl) return; // tab panel not in DOM + countEl.textContent = `${info.count} hash${info.count !== 1 ? 'es' : ''}`; + countEl.className = info.count > 0 ? 'hash-badge hash-community' : 'hash-badge hash-none'; + verEl.textContent = info.updated ? `v${info.version || 1} · ${info.updated}` : ''; + srcEl.textContent = info.source === 'imported' ? '(user-imported)' : '(bundled)'; + localEl.textContent = `${info.localCount} hash${info.localCount !== 1 ? 'es' : ''}`; + localEl.className = info.localCount > 0 ? 'hash-badge hash-local' : 'hash-badge hash-none'; + } catch (_) {} + try { + const summaries = await window.pork.hashGameSummary(); + const list = $('hdb-game-list'); + if (!list) return; + if (!summaries.length) { + list.innerHTML = '
    No hashes recorded yet. Open any game, go to Hash Verification, and click \'Hash All Files\'. Or use the Hash All Games / Backporks buttons on those pages.
    '; + return; + } + summaries.sort((a, b) => { + if (a.community_mismatch !== b.community_mismatch) return a.community_mismatch ? -1 : 1; + if (a.community_matches !== b.community_matches) return b.community_matches - a.community_matches; + const typeOrder = { game: 0, backpork: 1 }; + const tDiff = (typeOrder[a.hash_type] ?? 0) - (typeOrder[b.hash_type] ?? 0); + if (tDiff !== 0) return tDiff; + return a.game_id.localeCompare(b.game_id); + }); + list.innerHTML = `
    + + + ${summaries.map(s => { + const badge = s.community_mismatch + ? `⚠ Hash Mismatch` + : s.community_matches > 0 + ? `✓ Verified (${s.community_matches}/${s.hash_count})` + : `⧭ Hashed (${s.hash_count})`; + const typeLabel = s.hash_type === 'backpork' + ? `Backpork` + : `Game`; + const fwLabel = s.firmware_label ? escHtml(s.firmware_label) : '—'; + return ``; + }).join('')} +
    Game IDTypeFirmwareFiles HashedCommunity MatchesStatus
    ${escHtml(s.game_id)}${typeLabel}${fwLabel}${s.hash_count}${s.community_matches || '—'}${badge}
    `; + } catch (_) {} +} + +const _fmtBytes = b => b < 1024 ? `${b} B` : b < 1048576 ? `${(b/1024).toFixed(1)} KB` : b < 1073741824 ? `${(b/1048576).toFixed(1)} MB` : `${(b/1073741824).toFixed(2)} GB`; + +let _hashBulkRunning = false; + +function _setBulkProgress({ show = true, pct = 0, status = '', fileStatus = '' } = {}) { + const wrap = $('bulk-hash-progress'); + const bar = $('bulk-hash-bar'); + const pctEl = $('bulk-hash-pct'); + const statEl = $('hash-all-games-status'); + const fileEl = $('bulk-hash-file-status'); + if (!wrap) return; + wrap.style.display = show ? 'flex' : 'none'; + if (bar) bar.style.width = `${pct}%`; + if (pctEl) pctEl.textContent = `${pct}%`; + if (statEl) statEl.textContent = status; + if (fileEl) fileEl.textContent = fileStatus; +} + +async function _runBulkHash(source) { + if (_hashBulkRunning) { showToast('A hash operation is already running.', 'info'); return; } + _hashBulkRunning = true; + + const isGames = source === 'local'; + const btnEl = $(isGames ? 'btn-hash-all-games' : 'btn-hash-all-backporks'); + const backporksStat = $('hash-all-backporks-status'); + + btnEl.disabled = true; + if (isGames) { + _setBulkProgress({ show: true, pct: 0, status: 'Preparing…', fileStatus: '' }); + } else if (backporksStat) { + backporksStat.textContent = 'Preparing…'; + } + + try { + const { results, total, totalFilesHashed, totalBytesHashed } = await window.pork.hashBulk(source); + const ok = results.filter(r => r.ok).length; + const fail = results.filter(r => !r.ok).length; + const doneMsg = `Done ✓ — ${ok} game${ok !== 1 ? 's' : ''}, ${totalFilesHashed} file${totalFilesHashed !== 1 ? 's' : ''} (${_fmtBytes(totalBytesHashed || 0)})${fail ? `, ${fail} skipped` : ''}`; + if (isGames) { + _setBulkProgress({ show: true, pct: 100, status: doneMsg, fileStatus: '' }); + // Fade the progress block away after a few seconds + setTimeout(() => _setBulkProgress({ show: false }), 6000); + } else if (backporksStat) { + backporksStat.textContent = doneMsg; + } + // Refresh hash summaries and re-render badges everywhere + const summaries = await window.pork.hashGameSummary(); + _populateHashMaps(summaries); + renderGames(); + showToast(`Hash complete — ${totalFilesHashed} files (${_fmtBytes(totalBytesHashed || 0)})`, 'ok'); + } catch (e) { + const errMsg = 'Error: ' + e.message; + if (isGames) { + _setBulkProgress({ show: true, pct: 0, status: errMsg, fileStatus: '' }); + } else if (backporksStat) { + backporksStat.textContent = errMsg; + } + showToast('Hash failed: ' + e.message, 'error'); + } finally { + _hashBulkRunning = false; + btnEl.disabled = false; + } +} + +window.pork.on('hash:bulk:progress', ({ phase, done, total, percent, gameId, currentFile, fileSize, filePct, totalFilesHashed, totalBytesHashed }) => { + const backporksStat = $('hash-all-backporks-status'); + const isGamesPage = !!document.querySelector('#page-games.active'); + + const overallStatus = `Game ${done}/${total} (${percent}%) — ${gameId || ''}` + + (totalFilesHashed ? ` | ${totalFilesHashed} file${totalFilesHashed !== 1 ? 's' : ''} hashed` : '') + + (totalBytesHashed ? ` | ${_fmtBytes(totalBytesHashed)}` : ''); + + let fileStatus = ''; + if (currentFile) { + const sizeSuffix = fileSize ? ` (${_fmtBytes(fileSize)})` : ''; + const pctSuffix = (phase === 'file-progress' && filePct != null) ? ` — ${filePct}%` : ''; + fileStatus = `⧭ ${currentFile}${sizeSuffix}${pctSuffix}`; + } else if (phase === 'backup-done') { + fileStatus = `✓ ${gameId} complete`; + } + + // done = completed backups so far (0 during first game's files). + // Interpolate within the current slot: done/total gets us to the start of this slot, + // then add filePct spread across one slot width. + const barPct = (phase === 'file-progress' && filePct != null) + ? Math.min(100, Math.round(done / (total || 1) * 100 + filePct / (total || 1))) + : percent; + + _setBulkProgress({ show: true, pct: barPct, status: overallStatus, fileStatus }); + + // Mirror to backporks status if the games progress block isn't visible + if (backporksStat && !isGamesPage) { + backporksStat.textContent = overallStatus; + } +}); + +$('btn-hash-all-games').addEventListener('click', async () => { + const ok = await showConfirm('Hash All Game Backups?\n\nThis will SHA-256 every file in every local game backup. On large libraries this can take hours.\n\nThe app stays usable while hashing. Continue?'); + if (ok) _runBulkHash('local'); +}); +$('btn-hash-all-backporks').addEventListener('click', async () => { + const ok = await showConfirm('Hash All Backpork Backups?\n\nThis will SHA-256 every file in every backpork backup. On large libraries this can take hours.\n\nThe app stays usable while hashing. Continue?'); + if (ok) _runBulkHash('backpork'); +}); + +$('btn-hdb-export').addEventListener('click', async () => { + const p = await window.pork.hashExport(); + if (p) showToast(`Hash DB exported to ${p}`, 'ok'); +}); + +$('btn-import-community-hashes').addEventListener('click', async () => { + const btn = $('btn-import-community-hashes'); + btn.disabled = true; + try { + const result = await window.pork.hashImportCommunity(); + if (!result) { btn.disabled = false; return; } + showToast(`Community hash list imported — ${result.count} hashes loaded`, 'ok'); + await loadHashDbContent(); + // Refresh game summaries so badges update immediately + const summaries = await window.pork.hashGameSummary(); + _populateHashMaps(summaries); + renderGames(); + } catch (e) { + showToast('Import failed: ' + e.message, 'error'); + } finally { + btn.disabled = false; + } +}); +// ── Selective Clear dialog ────────────────────────────────────────────────────── +$('btn-clear-db').addEventListener('click', () => { + // Reset all checkboxes + document.querySelectorAll('#clear-select-options input[type="checkbox"]').forEach(cb => { cb.checked = false; }); + $('clear-select-ok').disabled = true; + openAccessibleDialog($('clear-select-overlay'), document.querySelector('#clear-select-options input')); +}); + +// Toggle "Clear Selected" button enable/disable based on any checked +document.querySelectorAll('#clear-select-options input[type="checkbox"]').forEach(cb => { + cb.addEventListener('change', () => { + const anyChecked = [...document.querySelectorAll('#clear-select-options input[type="checkbox"]')].some(c => c.checked); + $('clear-select-ok').disabled = !anyChecked; + }); +}); + +// Select All button +$('clear-select-all').addEventListener('click', () => { + document.querySelectorAll('#clear-select-options input[type="checkbox"]').forEach(cb => { cb.checked = true; }); + $('clear-select-ok').disabled = false; +}); + +// Cancel +$('clear-select-cancel').addEventListener('click', () => { + closeAccessibleDialog($('clear-select-overlay')); +}); + +// Clear Selected +$('clear-select-ok').addEventListener('click', async () => { + const checked = [...document.querySelectorAll('#clear-select-options input[type="checkbox"]')] + .filter(cb => cb.checked).map(cb => cb.value); + if (!checked.length) return; + + const labels = checked.map(v => v.charAt(0).toUpperCase() + v.slice(1)); + + // Hide the selection overlay so the confirm dialog is visible on top + closeAccessibleDialog($('clear-select-overlay')); + const confirmation = await showExactConfirmation(`Permanently delete: ${labels.join(', ')}?\n\nThis cannot be undone.`, 'WIS SELECTIE'); + if (!confirmation) { + // User cancelled — re-show the selection overlay with their choices preserved + openAccessibleDialog($('clear-select-overlay'), document.querySelector('#clear-select-options input:checked')); + return; + } + + const opts = {}; + checked.forEach(k => { opts[k] = true; }); + await window.pork.dbClearSelective(opts, confirmation); + + loadDatabase(); + loadHashDbContent(); + const summaries = await window.pork.hashGameSummary(); + _populateHashMaps(summaries); + renderGames(); + setStatus(`Cleared: ${labels.join(', ')}`, 'ok'); + showToast(`Cleared: ${labels.join(', ')}`, 'ok'); +}); + +// ── Row-level delete handlers ──────────────────────────────────────────────────── +async function _deleteSelectedRows(tableName, dbTable) { + const ids = _getCheckedIds(tableName); + if (!ids.length) return; + const confirmation = await showExactConfirmation(`Delete ${ids.length} ${tableName} row${ids.length !== 1 ? 's' : ''}?\n\nThis cannot be undone.`, 'VERWIJDER'); + if (!confirmation) return; + const { deleted } = await window.pork.dbDeleteRows(dbTable, ids, confirmation); + showToast(`Deleted ${deleted} ${tableName} row${deleted !== 1 ? 's' : ''}.`, 'ok'); + // Reload the affected table + if (tableName === 'games') await loadDbTable('games'); + if (tableName === 'backups') await loadDbTable('backups'); + if (tableName === 'hashes') await loadHashRowsTable(); + // Refresh game page data + const summaries = await window.pork.hashGameSummary(); + _populateHashMaps(summaries); + renderGames(); +} + +function _selectAllRows(tableName) { + const tableEl = tableName === 'games' ? $('db-games-table') + : tableName === 'backups' ? $('db-backups-table') + : $('db-hash-rows-table'); + tableEl.querySelectorAll('.db-row-check').forEach(cb => { cb.checked = true; cb.closest('tr').classList.add('db-row-selected'); }); + const selectAll = tableEl.querySelector('.db-select-all'); + if (selectAll) selectAll.checked = true; + _updateRowToolbar(tableName); +} + +function _deselectAllRows(tableName) { + const tableEl = tableName === 'games' ? $('db-games-table') + : tableName === 'backups' ? $('db-backups-table') + : $('db-hash-rows-table'); + tableEl.querySelectorAll('.db-row-check').forEach(cb => { cb.checked = false; cb.closest('tr').classList.remove('db-row-selected'); }); + const selectAll = tableEl.querySelector('.db-select-all'); + if (selectAll) selectAll.checked = false; + _updateRowToolbar(tableName); +} + +// Games toolbar +$('btn-delete-games').addEventListener('click', () => _deleteSelectedRows('games', 'games')); +$('btn-select-all-games').addEventListener('click', () => _selectAllRows('games')); +$('btn-deselect-all-games').addEventListener('click', () => _deselectAllRows('games')); + +// Backups toolbar +$('btn-delete-backups').addEventListener('click', () => _deleteSelectedRows('backups', 'backups')); +$('btn-select-all-backups').addEventListener('click', () => _selectAllRows('backups')); +$('btn-deselect-all-backups').addEventListener('click', () => _deselectAllRows('backups')); + +// Hashes toolbar +$('btn-delete-hashes').addEventListener('click', () => _deleteSelectedRows('hashes', 'game_hashes')); +$('btn-select-all-hashes').addEventListener('click', () => _selectAllRows('hashes')); +$('btn-deselect-all-hashes').addEventListener('click', () => _deselectAllRows('hashes')); + +async function _clearHashes() { + const confirmation = await showExactConfirmation('Delete all stored hashes from the database? This cannot be undone.', 'WIS HASHES'); + if (!confirmation) return; + await window.pork.dbClearHashes(confirmation); + await loadHashRowsTable(); + await loadHashDbContent(); + const summaries = await window.pork.hashGameSummary(); + _populateHashMaps(summaries); + renderGames(); + showToast('All hashes cleared.', 'ok'); +} + +$('btn-clear-hashes').addEventListener('click', _clearHashes); +$('btn-hdb-clear-hashes').addEventListener('click', _clearHashes); + diff --git a/renderer/modules/ecosystem.js b/renderer/modules/ecosystem.js new file mode 100644 index 0000000..88b5d9b --- /dev/null +++ b/renderer/modules/ecosystem.js @@ -0,0 +1,1496 @@ +'use strict'; + +let _ecosystemBuilt = false; +let _ecosystemLoaded = false; +let _ecosystemActiveTab = 'overview'; +let _ecosystemEtaHash = null; +let _ecosystemEtaProposed = null; +let _ecosystemCheatTimer = null; +let _ecosystemCheatOffset = 0; +let _ecosystemCheatRequest = 0; +const ECOSYSTEM_CHEAT_PAGE_SIZE = 50; +let _timelineFilterTimer = null; +let _ecosystemSaveCapabilities = null; +let _gameUpdatePlan = null; +let _gameUpdateRefreshTimer = null; +let _websrvFsPath = '/'; +let _websrvSmbPath = ''; +let _pacbrewPacket = null; +let _itemzflowPacket = null; +let _remotePlayPacket = null; +let _remotePlayPollTimer = null; +let _ecosystemComponentsPacket = null; + +function ecosystemDate(value) { + if (!value) return 'Not yet'; + try { return new Date(value).toLocaleString(window.i18n?.current || 'en'); } catch (_) { return 'Unknown'; } +} + +function ecosystemBytes(value) { + const bytes = Number(value || 0); + if (!bytes) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let amount = bytes; let unit = 0; + while (amount >= 1024 && unit < units.length - 1) { amount /= 1024; unit += 1; } + return `${amount >= 10 ? amount.toFixed(0) : amount.toFixed(1)} ${units[unit]}`; +} + +function ecosystemStatus(status, label = null) { + const normalized = ['compatible', 'online', 'ok'].includes(status) ? 'compatible' + : ['incompatible', 'offline', 'error'].includes(status) ? 'warning' + : status || 'unknown'; + const text = label || ({ compatible: 'Compatible', warning: 'Warning', unknown: 'Unknown' }[normalized] || status || 'Unknown'); + return `${escHtml(text)}`; +} + +function buildEcosystemPage() { + if (_ecosystemBuilt) return; + const page = $('page-ecosystem'); + page.innerHTML = ` + +
    + ${[ + ['overview', 'Command Center'], ['components', 'Components'], ['updates', 'Payload updates'], ['game-updates', 'Game updates'], ['etahen', 'Console profile'], + ['live', 'Live Session'], ['remote-play', 'Remote Play'], ['files', 'Files'], ['homebrew', 'Homebrew'], ['sources', 'Sources'], ['plugins', 'Plugins'], + ['cheats', 'Cheats & patches'], ['saves', 'Save Vault'], ['itemzflow', 'Itemzflow'], ['platform-lab', window.t('platformLab.tab', 'Platform Lab')], ['research', 'Research Lab'], ['timeline', 'Timeline'], + ].map(([id, label], index) => ``).join('')} +
    +

    Loading overview…

    + + + + + + + + + + + + + + + + `; + bindEcosystemEvents(); + window.ChimeraLinkUx?.mount({ root: $('etahen-link-root'), loadStatus: () => window.pork.etahenLinkInventoryStatus(), translate: (key, fallback) => window.t?.(key, fallback) || fallback }); + _ecosystemBuilt = true; +} + +async function ecosystemBusy(button, task) { + const prior = button?.textContent; + if (button) { button.disabled = true; button.textContent = window.t('common.loading', 'Loading…'); } + try { return await task(); } + finally { if (button) { button.disabled = false; button.textContent = prior; } } +} + +function localizedReadinessStep(step) { + if (window.i18n?.current === 'nl') return step; + const english = { + profile: { label: 'Console profile', detail: step.state === 'ready' ? step.detail : 'IP address not configured' }, + transport: { label: 'File transport', detail: step.state === 'ready' ? 'FTP transport reachable' : 'etaHEN FTP or ftpsrv is required for files' }, + elfldr: { label: 'ELF Loader', detail: 'Foundation for Payload Manager and multiple homebrew services' }, + 'payload-manager': { label: 'Payload Manager', detail: 'Catalog, imports and explicit payload launch' }, + websrv: { label: 'websrv', detail: 'Homebrew, filesystem and additional network services' }, + diagnostics: { label: 'Diagnostics', detail: 'Klog is optional and only needed during investigation' }, + }; + return { ...step, ...(english[step.id] || {}) }; +} + +function localizedEcosystemAlert(alert, field) { + const fallback = alert?.[field] || ''; + const count = String(alert?.title || '').match(/\d+(?:\/\d+)?/)?.[0] || ''; + return window.tf?.(`navigation.alerts.${alert?.id}.${field}`, fallback, { count }) || fallback; +} + +function localizedEcosystemError(value) { + const parts = [...new Set(String(value || '').split(/\s+·\s+/).map(part => part.trim()).filter(Boolean))]; + const message = parts.join(' · '); + if (window.i18n?.current === 'nl') return message; + return message + .replace(/Configureer eerst een geldig PS5-hostadres(?: in Instellingen)?\.?/gi, 'Configure a valid PS5 host in Settings first.') // i18n: normalize persisted server copy + .replace(/Maak eerst verbinding met de PS5 via FTP\.?/gi, 'Connect to the PS5 through FTP first.') // i18n: normalize persisted server copy + .replace(/Start ([^.]+) op de PS5\.?/gi, 'Start $1 on the PS5.'); +} + +function localizedEcosystemActivity(value) { + const message = String(value || ''); + if (window.i18n?.current === 'nl') return message; + return message + .replace('Console Health wacht op configuratie', 'Console Health is waiting for configuration') + .replace(/(\d+) aandachtspunt\(en\) gevonden\./, '$1 attention item(s) found.'); // i18n: normalize persisted activity copy. +} + +function ecosystemCapabilityText(key, fallback) { + return window.t?.(`ecosystem.capabilities.${key}`, fallback) || fallback; +} + +function renderCapabilityGraph(graph) { + if (!graph?.features) return ''; + const groups = ['core', 'connected', 'planned']; + return `
    +

    ${ecosystemCapabilityText('title', 'Capability map')}

    ${ecosystemCapabilityText('description', 'Live, conditional and planned functionality for the active console profile.')}

    ${ecosystemStatus(graph.summary?.blocked ? 'warning' : 'compatible', `${graph.summary?.available || 0}/${graph.summary?.total || 0} ${ecosystemCapabilityText('available', 'available')}`)}
    +
    ${[ + ['available', graph.summary?.available || 0], ['conditional', graph.summary?.conditional || 0], ['blocked', graph.summary?.blocked || 0], ['bindings', graph.bindings?.length || 0], + ].map(([label, count]) => `
    ${count}${escHtml(ecosystemCapabilityText(`status.${label}`, label))}
    `).join('')}
    +
    ${groups.map(group => `

    ${escHtml(ecosystemCapabilityText(`group.${group}`, group))}

    ${graph.features.filter(item => item.group === group).map(item => `
    ${escHtml(ecosystemCapabilityText(`feature.${item.id}`, item.id))}${escHtml(ecosystemCapabilityText(`reason.${item.reasonCode}`, item.reasonCode))} · ${escHtml(ecosystemCapabilityText(`mode.${item.mode}`, item.mode))}
    ${ecosystemStatus(item.status === 'available' ? 'compatible' : item.status === 'conditional' ? 'warning' : item.status === 'blocked' ? 'offline' : 'unknown', ecosystemCapabilityText(`status.${item.status}`, item.status))}
    `).join('')}
    `).join('')}
    +

    ${graph.freshness?.stale ? ecosystemCapabilityText('stale', 'Upstream evidence is stale; refresh components before relying on version-sensitive actions.') : ecosystemCapabilityText('fresh', 'Upstream evidence is current.')}

    +
    `; +} + +function renderEcosystemOverview(data, healthPacket = {}, activity = [], healthHistory = [], capabilityGraph = null) { + const services = data.services?.services || []; + const online = services.filter(service => service.reachable).length; + const report = healthPacket.report || null; + const healthAge = report ? Math.max(0, Date.now() - Number(report.generatedAt || 0)) : null; + const healthStale = healthAge !== null && healthAge > 5 * 60 * 1000; + const alerts = report?.alerts || []; + const openAlerts = alerts.filter(alert => !alert.acknowledged); + const readiness = data.readiness || { state: 'not-configured', steps: [] }; + const readinessSteps = (readiness.steps || []).map(localizedReadinessStep); + const isDutch = window.i18n?.current === 'nl'; + const readinessLabels = new Map(readinessSteps.map(step => [step.id, step.label])); + $('ecosystem-overview').innerHTML = ` +
    +

    Console Readiness

    Dependencies and safe next steps. Chimera starts nothing automatically.

    ${ecosystemStatus(readiness.state === 'ready' ? 'compatible' : readiness.state === 'attention' ? 'warning' : 'unknown', readiness.state === 'ready' ? 'Ready' : readiness.state === 'attention' ? 'Action needed' : 'Not configured')}
    +
    ${[ + [isDutch ? 'Kernverbinding' : 'Core connection', readinessSteps.filter(step => step.required)], + [isDutch ? 'Optionele diensten' : 'Optional services', readinessSteps.filter(step => !step.required)], + ].map(([label, steps]) => `

    ${label}${steps.filter(step => step.state === 'ready').length}/${steps.length}

    ${steps.map((step, index) => ``).join('')}
    `).join('')}
    +
    + ${renderCapabilityGraph(capabilityGraph)} +
    + ${[ + ['Console Health', report ? (report.score == null ? 'Not configured' : `${report.score}/100`) : 'Not yet'], + ['Services online', `${online}/${services.length}`], + ['Managed assets', (data.homebrew?.count || 0) + (data.plugins?.count || 0) + (data.cheats?.count || 0)], + ['Protected data', `${data.saveVault?.snapshots || 0} snapshots`], + ].map(([label, value]) => `
    ${escHtml(value)}${escHtml(label)}
    `).join('')} +
    +
    +

    ${isDutch ? 'PS5-diensten' : 'PS5 services'}

    ${data.services?.error ? `

    ${escHtml(localizedEcosystemError(data.services.error))}

    ` : services.map(service => `
    ${escHtml(service.label)}${isDutch ? 'Poort' : 'Port'} ${service.port}${service.error ? ` · ${escHtml(service.error)}` : ''}
    ${ecosystemStatus(service.reachable ? 'online' : 'offline', service.reachable ? 'Online' : 'Offline')}
    `).join('')}
    +

    Safety policy

    No automatic executionUpdates are never started immediately on the PS5.

    Source and checksumOfficial upstream and published digests remain visible.

    Expert isolationShell and GDB are not proxied through the public Chimera interface.

    +
    +
    +

    ${isDutch ? 'Actiecentrum' : 'Action Center'}

    ${report ? `${isDutch ? 'Scan van' : 'Scan from'} ${ecosystemDate(report.generatedAt)}${healthStale ? ` · ${isDutch ? 'verouderd' : 'stale'}` : ''} · ${openAlerts.length} open` : (isDutch ? 'Voer een diepe healthscan uit voor concrete acties.' : 'Run a deep health scan for concrete actions.')}

    +
    ${alerts.length ? alerts.map(alert => `
    ${escHtml(localizedEcosystemAlert(alert, 'title'))}${escHtml(localizedEcosystemAlert(alert, 'detail'))}
    ${ecosystemStatus(alert.severity === 'critical' ? 'error' : alert.severity === 'warning' ? 'warning' : 'unknown', alert.severity)}${alert.action ? `` : ''}${alert.acknowledged ? 'Read' : ''}
    `).join('') : '
    ✓ No known issues.
    '}
    +
    +

    Health trend & activity

    ${healthHistory.some(item => item.score != null) ? `
    ${healthHistory.filter(item => item.score != null).slice(0, 20).reverse().map(item => `
    ${escHtml(item.score)}
    `).join('')}
    ` : '

    The trend appears after multiple configured health scans.

    '}
    ${activity.length ? activity.slice(0, 12).map(item => `
    ${escHtml(localizedEcosystemActivity(item.title))}${escHtml(localizedEcosystemActivity(item.detail || item.type))}
    `).join('') : '

    No management activity recorded yet.

    '}
    +
    `; + const health = $('ecosystem-health'); + health.textContent = report ? (report.score == null ? 'Health · not configured' : `Health ${report.score}/100 · ${report.grade}${healthStale ? ' · stale' : ''}`) : (services.length ? `${online}/${services.length} PS5 services online` : (data.services?.error || 'PS5 not configured')); + health.className = `operations-health ${report?.score == null ? '' : !healthStale && (report?.score >= 75 || (!report && online)) ? 'healthy' : 'warning'}`; + const badge = $('nav-ecosystem-badge'); + const issues = openAlerts.filter(alert => ['critical', 'warning'].includes(alert.severity)).length || Number(data.plugins?.conflicts || 0); + if (badge) { badge.hidden = issues === 0; badge.textContent = issues || ''; } +} + +async function loadEcosystemOverview() { + const [data, healthPacket, activity, healthHistory, capabilityGraph] = await Promise.all([window.pork.ecosystemOverview(), window.pork.ecosystemHealthLatest(), window.pork.ecosystemActivity(50), window.pork.ecosystemHealthHistory(30), window.pork.ecosystemCapabilityGraph()]); + renderEcosystemOverview(data, healthPacket, activity, healthHistory, capabilityGraph); + _ecosystemLoaded = true; +} + +function ecosystemComponentGroup(component) { + if (['experimental-os', 'expert', 'kernel'].includes(component.kind) || component.risk === 'expert') return 'Experimental & expert'; + if (['catalog', 'plugin', 'plugin-catalog', 'database'].includes(component.kind)) return 'Catalogs, plugins & data'; + if (['homebrew', 'client-companion', 'download-tool', 'remote-play-helper', 'account-tool', 'diagnostic'].includes(component.kind)) return 'Applications & tools'; + return 'Core services'; +} + +function renderEcosystemComponents() { + const result = _ecosystemComponentsPacket; + if (!result) return; + const query = String($('ecosystem-components-search')?.value || '').trim().toLowerCase(); + const platform = $('ecosystem-components-platform')?.value || 'all'; + const risk = $('ecosystem-components-risk')?.value || 'all'; + const filtered = result.components.filter(component => { + const haystack = `${component.name} ${component.kind} ${component.platform} ${(component.dependencies || []).join(' ')}`.toLowerCase(); + return (!query || haystack.includes(query)) + && (platform === 'all' || component.platform === platform) + && (risk === 'all' || component.risk === risk); + }); + const groups = new Map(); + for (const component of filtered) { + const group = ecosystemComponentGroup(component); + if (!groups.has(group)) groups.set(group, []); + groups.get(group).push(component); + } + const counts = { + visible: filtered.length, + total: result.components.length, + compatible: filtered.filter(component => component.compatibility?.status === 'compatible').length, + attention: filtered.filter(component => component.compatibility?.status === 'incompatible' || component.risk === 'expert').length, + }; + counts.sourceDependent = Math.max(0, counts.visible - counts.compatible - counts.attention); + $('ecosystem-components').innerHTML = ` +
    +
    ${counts.visible}/${counts.total}Visible components
    +
    ${counts.compatible}Compatible
    +
    ${counts.attention}Needs attention
    +
    ${counts.sourceDependent}Source-dependent
    +
    ${ecosystemDate(result.checkedAt)}Last upstream check
    +
    + ${groups.size ? [...groups.entries()].map(([label, components]) => `

    ${escHtml(label)}

    ${components.length} component${components.length === 1 ? '' : 's'}
    ${components.map(component => ` +
    +
    ${escHtml(component.name)}${escHtml(component.kind)} · ${escHtml(component.platform)} · ${escHtml(component.trust)}
    +
    ${escHtml(component.release?.tag || 'Unknown version')}${ecosystemStatus(component.compatibility?.status, component.compatibility?.status === 'incompatible' ? 'Incompatible' : component.compatibility?.status === 'compatible' ? 'Compatible' : 'Source-dependent')}
    +

    ${escHtml(component.compatibility?.reason || '')}${component.dependencies?.length ? ` Depends on: ${component.dependencies.map(escHtml).join(', ')}.` : ''}

    + +
    `).join('')}
    `).join('') : '
    No matching componentsClear one or more filters to expand the catalog.
    '}`; +} + +async function loadEcosystemComponents() { + _ecosystemComponentsPacket = await window.pork.ecosystemComponents(); + renderEcosystemComponents(); +} + +function ecosystemUpdateState(item) { + const labels = { current: 'Current', ahead: 'Console newer', 'version-different': 'Manual review', 'update-available': 'Update', staged: 'Staged', 'installed-unknown': 'Release unknown', 'not-tracked': 'Not tracked' }; + const status = ['current', 'ahead'].includes(item.state) ? 'compatible' : ['update-available', 'version-different'].includes(item.state) ? 'warning' : 'unknown'; + return ecosystemStatus(status, labels[item.state] || item.state); +} + +function ecosystemEvidenceSummary(item) { + if (!item.installedSource) return ` ${window.t('ecosystem.payloadEvidence.unavailable', 'evidence unavailable')}`; + const confidence = item.installedSourceConfidence || 'unknown'; + const seenAt = item.installedSourceSeenAt ? ecosystemDate(item.installedSourceSeenAt) : window.t('ecosystem.payloadEvidence.unknownTime', 'unknown time'); + return ` ${'\u00B7'} ${escHtml(window.t('ecosystem.payloadEvidence.source', 'source'))}: ${escHtml(item.installedSource)} ${'\u00B7'} ${escHtml(window.t('ecosystem.payloadEvidence.confidence', 'confidence'))}: ${escHtml(confidence)} ${'\u00B7'} ${escHtml(window.t('ecosystem.payloadEvidence.seen', 'seen'))}: ${escHtml(seenAt)}`; +} + +function ecosystemEvidenceConflictSummary(item) { + if (!item.installedVersionConflicts?.length) return ''; + return ` ${'\u2014'} conflicts ${item.installedVersionConflicts.map(conflict => `${escHtml(conflict.version)} (${escHtml(conflict.sources.join(', '))})`).join(' ; ')}`; +} + +const ecosystemBullet = '\u00B7'; + +function renderEcosystemUpdates(inventory, plan) { + $('ecosystem-update-summary').innerHTML = `
    ${[ + ['Tracked', inventory.stats.total], ['Current', inventory.stats.current], ['Detected', inventory.stats.detected], ['Console newer', inventory.stats.ahead], ['Updates ready', inventory.stats.updates], ['Blocked steps', plan.blocked], + ].map(([label, value]) => `
    ${escHtml(value)}${escHtml(label)}
    `).join('')}
    +
    Safe dependency order${plan.steps.length ? plan.steps.map(step => step.component.name).join(' → ') : 'No tracked updates yet. Record the installed console version below to enable reliable comparison.'}
    `; + const groups = [ + { id: 'ready', title: 'Ready to prepare', description: 'A newer upstream asset can be downloaded and verified locally.', items: inventory.components.filter(item => item.state === 'update-available') }, + { id: 'version', title: 'Version required', description: 'Enter the version currently installed on your console.', items: inventory.components.filter(item => ['installed-unknown', 'not-tracked', 'staged'].includes(item.state)) }, + { id: 'review', title: 'Manual review', description: 'Potential downgrade or incomparable version; Chimera will not prepare it automatically.', items: inventory.components.filter(item => ['ahead', 'version-different'].includes(item.state)) }, + { id: 'current', title: 'Current', description: 'Installed and upstream versions match.', items: inventory.components.filter(item => item.state === 'current') }, + ]; + + const renderRow = item => { + const step = plan.steps.find(entry => entry.component.id === item.id); + const conflicts = ecosystemEvidenceConflictSummary(item); + const summaryLine = `${escHtml(item.kind)} ${'\u00B7'} console ${escHtml(item.installedVersion || 'unknown')}${ecosystemEvidenceSummary(item)} ${'\u00B7'} upstream ${escHtml(item.latestVersion || 'unknown')} ${'\u00B7'} locally staged ${escHtml(item.stagedVersion || 'no')}`; + const details = `${conflicts}${conflicts && (item.dependencies?.length || step?.blockers?.length || item.compatibility?.reason) ? ' \u00B7 ' : ''}` + + `${item.dependencies?.length ? `Depends on ${item.dependencies.map(escHtml).join(', ')}` : ''}${conflicts && item.dependencies?.length ? '. ' : ''}` + + `${step?.blockers?.length ? `Blocked: ${step.blockers.map(escHtml).join(' ')}` : item.compatibility?.reason || ''}`; + const actions = item.assets.length + ? `${['ahead', 'version-different'].includes(item.state) ? 'Downgrade blocked' : ''}` + : 'Sync to discover assets'; + const tracking = window.t(`ecosystem.payloadEvidence.mode.${item.trackingMode}`, item.trackingMode); + const automaticButton = item.manualEvidence ? `` : ''; + return `
    ${escHtml(item.name)}${ecosystemUpdateState(item)}
    ${summaryLine}${escHtml(tracking)}${details ? ` ${ecosystemBullet} ${details}` : ''}
    ${automaticButton}${actions}
    `; + }; + $('ecosystem-update-plan').innerHTML = groups.map(group => `
    +

    ${escHtml(group.title)}

    ${escHtml(group.description)}
    ${group.items.length}
    + ${group.items.length ? `
    ${group.items.map(renderRow).join('')}
    ` : '

    No components in this section.

    '} +
    `).join(''); +} + +async function loadEcosystemUpdates() { + const [inventory, plan, policy, beaconArtifact] = await Promise.all([ + window.pork.ecosystemInventory(), + window.pork.ecosystemUpdatePlan(), + window.pork.ecosystemSupplyPolicy(), + window.pork.chimeraBeaconArtifact(), + ]); + $('ecosystem-repository-url').value = `${window.location.origin}/payloads.json`; + renderEcosystemUpdates(inventory, plan); + $('ecosystem-supply-official').checked = policy.officialSourcesOnly; + $('ecosystem-supply-https').checked = policy.requireHttps; + $('ecosystem-supply-hash').checked = policy.requirePublishedHashForExecution; + if (!state.connected) { + renderPayloadRuntimeUnavailable('The PS5 target is offline. Local update planning remains available above.'); + return; + } + const timeout = (promise, ms) => Promise.race([ + promise, + new Promise((_, reject) => setTimeout(() => reject(new Error('The PS5 runtime check timed out.')), ms)), + ]); + try { + const [watchdog, runtimeUpdates] = await Promise.all([ + timeout(window.pork.ecosystemPayloadWatchdog(), 4_000), + timeout(window.pork.ecosystemPayloadUpdates(), 4_000), + ]); + renderPayloadRuntime(watchdog, runtimeUpdates, beaconArtifact); + } catch (error) { + renderPayloadRuntimeUnavailable(localizedEcosystemError(error.message)); + } +} + +function renderPayloadRuntimeUnavailable(message) { + $('ecosystem-watchdog').innerHTML = `
    Runtime status unavailable${escHtml(message)}
    `; + $('ecosystem-runtime-updates').innerHTML = '
    Safety boundaryCatalog synchronization, version comparison and verified staging remain available locally. Runtime installation is never attempted while the PS5 target is offline.
    '; +} + +function renderPayloadRuntime(watchdog, updates, beaconArtifact) { + $('ecosystem-watchdog').innerHTML = `
    ${[ + ['Library', watchdog.manager?.installed || 0], ['Running', watchdog.stats?.running || 0], ['Autoload offline', watchdog.stats?.autoloadOffline || 0], ['Issues', watchdog.stats?.issues || 0], ['Updates', updates.stats?.updates || 0], + ].map(([label, value]) => `
    ${escHtml(value)}${escHtml(label)}
    `).join('')}
    +
    ${watchdog.services.map(service => `
    ${escHtml(service.name)}${escHtml(service.filename || 'no payload file')} · port ${escHtml(service.port)}${service.installedVersion ? ` · file ${escHtml(service.installedVersion)}` : ''}${service.runtimeVersion ? ` · runtime ${escHtml(service.runtimeVersion)}` : ''}
    ${ecosystemStatus(service.running ? 'online' : service.state === 'autoload-offline' ? 'warning' : 'unknown', service.running ? 'Running' : service.autoload ? 'Autoload · offline' : service.installed ? 'Installed' : 'Not present')}
    `).join('')}
    + ${watchdog.issues?.length ? `
    Attention required${watchdog.issues.map(issue => `${escHtml(issue.message)}`).join('')}
    ` : '
    ✓ No autoload or runtime issues found.
    '}`; + const actionable = updates.candidates.filter(item => item.status === 'update-available'); + $('ecosystem-runtime-updates').innerHTML = `
    Chimera Sentinel ${escHtml(beaconArtifact.version)} · ${beaconArtifact.built ? 'build ready' : 'not built'}Paired read-only UDP ${beaconArtifact.port}: HMAC-SHA256, fixed bounded telemetry and visible legacy Beacon 0.1 detection. No writes, persistence, serial number, memory access or command execution.${beaconArtifact.sha256 ? `SHA-256 ${escHtml(beaconArtifact.sha256)}` : 'The ELF appears after a complete container build.'}
    ${beaconArtifact.publicUrl ? `Download verified ELF` : ''}
    Safe Payload Manager update flow${updates.stats.current} current · ${updates.stats.review} need manual review · ${updates.stats.newer} locally newer. Installing only adds the selected file; starting and restarting remain disabled.
    ${actionable.length ? `
    ${actionable.map(item => `
    ${escHtml(item.installed.name)}${escHtml(item.installed.version || 'unknown')} → ${escHtml(item.latest.version || 'unknown')} · ${escHtml(item.latest.sourceName)}${escHtml(item.latest.description || '')}
    `).join('')}
    ` : '

    No safely comparable newer Payload Manager versions found.

    '}${updates.recommendations?.length ? `

    Matching extensions · never installed automatically

    ${updates.recommendations.map(item => `
    ${escHtml(item.name)}${escHtml(item.version || 'unknown version')} · ${escHtml(item.risk)} · ${escHtml(item.reason)}
    ${ecosystemStatus(item.risk === 'expert' ? 'warning' : 'unknown', 'Via Payload Manager')}
    `).join('')}
    ` : ''}`; +} + +function etaPatchFromForm() { + const patch = {}; + $('ecosystem-etahen-fields').querySelectorAll('[data-eta-key]').forEach(input => { patch[input.dataset.etaKey] = input.type === 'checkbox' ? (input.checked ? 1 : 0) : Number(input.value); }); + return patch; +} + +function renderEtaDiff(result) { + _ecosystemEtaProposed = result.proposed; + $('ecosystem-etahen-diff').innerHTML = `${(result.warnings || []).map(warning => `

    âš  ${escHtml(warning)}

    `).join('')}${result.diff?.length ? result.diff.map(item => `
    ${escHtml(item.label)}${escHtml(item.before ?? '—')} → ${escHtml(item.after ?? '—')}
    `).join('') : '
    ✓ No changes.
    '}`; + $('ecosystem-etahen-apply').disabled = !result.diff?.length; +} + +async function loadEtaHen() { + const [config, history] = await Promise.all([window.pork.ecosystemEtaHenGet(), window.pork.ecosystemEtaHenHistory()]); + _ecosystemEtaHash = config.hash; + _ecosystemEtaProposed = null; + $('ecosystem-etahen-raw').value = config.raw; + const preset = $('ecosystem-etahen-preset'); + preset.innerHTML = '' + Object.entries(config.presets || {}).map(([id, value]) => ``).join(''); + $('ecosystem-etahen-fields').innerHTML = Object.entries(config.schema || {}).map(([key, definition]) => { + const value = config.values?.[key] ?? '0'; + return ``; + }).join(''); + $('ecosystem-etahen-history').innerHTML = history.length ? `

    Restore points

    ${history.slice(0, 20).map(item => `
    ${escHtml(item.label)}${ecosystemDate(item.createdAt)} · ${item.diff?.length || 0} change(s)
    `).join('')}` : '

    No configuration restore points yet.

    '; + $('ecosystem-etahen-diff').innerHTML = '

    Choose a preset or change known fields, then create a preview.

    '; + $('ecosystem-etahen-apply').disabled = true; +} + +function renderHomebrew(result) { + $('ecosystem-homebrew').innerHTML = result.items?.length ? result.items.map(item => `
    ${item.iconUrl ? `` : '
    HB
    '}
    ${escHtml(item.name)}${escHtml(item.location)} · ${escHtml(item.executable)}${item.duplicate ? ' · duplicate' : ''}${escHtml(item.path)}
    `).join('') : '
    No homebrew foundStart websrv or FTP and run a new scan.
    '; +} + +function renderPacbrew(packet) { + _pacbrewPacket = packet; + const query = String($('ecosystem-pacbrew-search').value || '').trim().toLowerCase(); + const kind = $('ecosystem-pacbrew-kind').value; + const filtered = (packet.packages || []).filter(item => (!query || item.name.toLowerCase().includes(query)) && (kind === 'all' || item.kind === kind)); + const counts = Object.fromEntries(['application', 'service', 'library', 'toolchain'].map(type => [type, (packet.packages || []).filter(item => item.kind === type).length])); + $('ecosystem-pacbrew').innerHTML = ` + ${packet.warning ? `
    ${packet.stale ? 'Cached catalog' : 'Fallback transport active'}

    ${escHtml(packet.warning)}

    ` : ''} +
    +
    ${filtered.length}/${packet.packages?.length || 0}Visible recipes
    +
    ${counts.application || 0}Applications
    +
    ${counts.library || 0}Libraries
    +
    ${counts.service || 0}Services
    +
    +

    Release ${escHtml(packet.release?.tag || 'unknown')} · checked ${ecosystemDate(packet.checkedAt)} · ${escHtml(packet.transport || 'cached')} · recipes are never executed

    +
    ${filtered.slice(0, 500).map(item => `
    ${escHtml(item.name)}${escHtml(item.kind)} · source ${escHtml(String(item.sourceSha || '').slice(0, 12))}
    ${ecosystemStatus('unknown', 'Recipe only')}
    `).join('')}
    `; +} + +async function loadHomebrew() { + const [homebrew, pacbrew] = await Promise.all([window.pork.ecosystemHomebrewList(), window.pork.ecosystemPacbrewCatalog()]); + renderHomebrew(homebrew); + renderPacbrew(pacbrew); +} + +function renderWebsrvDiscovery(result) { + const services = result.services || []; + $('ecosystem-websrv-discovery').innerHTML = services.length ? `
    ${services.length} mDNS service(s) discovered by websrv
    ${services.map(service => { + const name = service.name || service.instance || service.hostname || service.host || 'Onbekende dienst'; + const detail = [service.type, service.address || service.host, service.port].filter(Boolean).join(' · '); + return `${escHtml(name)}${detail ? ` ${escHtml(detail)}` : ''}`; + }).join('')}
    ` : '

    websrv found no mDNS services on the PS5 network.

    '; +} + +function renderSources(sources) { + $('ecosystem-sources').innerHTML = sources.length ? sources.map(source => `
    ${escHtml(source.label)}${escHtml(source.protocol.toUpperCase())} · ${escHtml(source.host)}:${source.port}${escHtml(source.path)} · password ${source.passwordConfigured ? 'configured' : 'not configured'}
    `).join('') : '

    No remote sources configured yet.

    '; +} + +function renderEzRemote(packet) { + $('ezremote-status').innerHTML = packet.reachable + ? `Online · ${escHtml(packet.version || 'unknown version')}${escHtml(packet.host)}:${packet.port} · resume after server restart supported · pause/cancel unsupported` + : `Offline op ${escHtml(packet.host || 'PS5')}:${packet.port || 6701}${escHtml(packet.error || 'Start ezRemote Server op de PS5.')}`; + const rows = packet.downloads || []; + $('ezremote-downloads').innerHTML = rows.length ? `
    ${rows.slice(0, 100).map(item => `
    ${escHtml(item.path || 'Unknown destination')}${ecosystemBytes(item.transferred)} / ${item.size ? ecosystemBytes(item.size) : '?'} · ${item.percent}%
    ${ecosystemStatus(item.state === 'success' ? 'online' : item.state === 'failed' ? 'offline' : 'warning', item.state)}
    `).join('')}
    ` : '

    ezRemote Server reports no downloads yet.

    '; +} + +async function loadSources() { + const [sources, status] = await Promise.all([window.pork.ecosystemSources(), window.pork.ezRemoteStatus()]); + renderSources(sources); + const select = $('ezremote-source'); const selected = select.value; + select.innerHTML = '' + sources.filter(source => source.enabled !== false).map(source => ``).join(''); + if (sources.some(source => source.id === selected)) select.value = selected; + renderEzRemote(status); +} + +function renderPlugins(result, catalog = []) { + const summary = `
    ${result.items?.length || 0} plugins · ${(result.roots || []).length} available locationsLast scan: ${result.scannedAt ? ecosystemDate(result.scannedAt) : 'not yet'}. ${result.skippedUnmountedUsbRoots || 0} unmounted USB locations were skipped without generating errors.
    `; + const installed = result.items?.length + ? result.items.map(item => `
    ${escHtml(item.name)}${escHtml(item.location)} · ${ecosystemBytes(item.size)}${item.duplicate ? ' · duplicate' : ''}${item.effective ? ' · highest active priority' : ''}${escHtml(item.path)}
    ${ecosystemStatus(item.enabled ? 'compatible' : 'unknown', item.enabled ? 'Enabled' : 'Disabled')}
    `).join('') + : '
    No installed plugins foundChoose a reviewed source below or upload a local ELF/PRX through the guarded installer.
    '; + const reviewed = catalog.length + ? `

    Reviewed plugin sources

    Discovery only · installation remains explicit
    ${catalog.map(item => `
    ${escHtml(item.name)}${escHtml(item.platform)} · ${escHtml(item.trust)} · ${escHtml(item.release?.tag || 'version unknown')}

    ${escHtml(item.compatibility?.reason || 'Review the upstream release before installing.')}

    `).join('')}
    ` + : '

    No reviewed plugin sources are present in the component catalog.

    '; + $('ecosystem-plugins').innerHTML = `${summary}

    Installed plugins

    ${installed}${reviewed}`; +} + +async function loadPlugins() { + const [plugins, components] = await Promise.all([ + window.pork.ecosystemPlugins(), + window.pork.ecosystemComponents().catch(() => ({ components: [] })), + ]); + const catalog = (components.components || []).filter(item => /plugin/i.test(`${item.kind} ${item.name}`)); + renderPlugins(plugins, catalog); +} + +async function loadCheatsV2() { + const gameContext = consumeGameWorkspaceContext('cheats'); + if (gameContext) { $('ecosystem-cheats-search').value = gameContext; _ecosystemCheatOffset = 0; } + const request = ++_ecosystemCheatRequest; + const filters = { query: $('ecosystem-cheats-search').value, platform: $('ecosystem-cheats-platform').value, kind: $('ecosystem-cheats-kind').value, installedOnly: $('ecosystem-cheats-installed').checked, limit: ECOSYSTEM_CHEAT_PAGE_SIZE, offset: _ecosystemCheatOffset }; + const result = await window.pork.ecosystemCheats(filters); + if (request !== _ecosystemCheatRequest) return; + _ecosystemCheatOffset = result.offset || 0; + const first = result.total ? _ecosystemCheatOffset + 1 : 0; + const last = _ecosystemCheatOffset + result.items.length; + const list = result.items.length + ? `
    ${result.items.map(item => { + const match = item.compatibility === 'exact' + ? ecosystemStatus('compatible', 'Exact version') + : item.compatibility === 'title-match' + ? ecosystemStatus('compatible', 'Title match') + : item.compatibility === 'version-mismatch' + ? ecosystemStatus('warning', 'Version mismatch') + : item.compatibility === 'version-unknown' + ? ecosystemStatus('unknown', 'Version unknown') + : ecosystemStatus('unknown', 'Not local'); + return `
    ${escHtml(item.installedTitle || item.title)}${escHtml(item.gameId || 'Unlinked')} · ${escHtml(item.platform)} · ${escHtml(item.kind)} · ${escHtml(item.format.toUpperCase())} · ${escHtml(item.sourceName)}${item.version ? `Target version ${escHtml(item.version)}` : 'No target version supplied'}${item.installedVersion ? ` · installed ${escHtml(item.installedVersion)}` : ''}${item.conflict ? ' · multiple formats available' : ''}
    ${match}${item.deployable ? '' : `View only`}
    `; + }).join('')}
    ` + : '
    No cheats or patches matchAdjust the filters or synchronize the reviewed sources to refresh the catalog.
    '; + const compatibility = result.compatibility || {}; + $('ecosystem-cheats').innerHTML = `

    ${first}–${last} of ${result.total} results · ${Number(compatibility.exact || 0) + Number(compatibility.titleMatch || 0)} recommended · ${Number(compatibility.versionMismatch || 0)} mismatched · synced ${ecosystemDate(result.syncedAt)}

    ${(result.errors || []).map(error => `

    ${escHtml(error.sourceId)}: ${escHtml(error.error)}

    `).join('')}${list}`; +} + +function renderRemoteSaves(result) { + const groups = new Map(); + for (const save of result.saves || []) { const id = String(save.title_id || 'UNKNOWN'); if (!groups.has(id)) groups.set(id, { titleId: id, title: save.title_name || id, saves: [] }); groups.get(id).saves.push(save); } + $('ecosystem-remote-saves').innerHTML = groups.size ? [...groups.values()].map(group => `
    ${escHtml(group.title)}${escHtml(group.titleId)} · ${group.saves.length} save(s) · ${group.saves.some(save => save.type === 'ps5') ? 'PS5' : 'PS4'}
    `).join('') : '

    No saves found.

    '; +} + +async function loadSaveVault() { + const gameContext = consumeGameWorkspaceContext('saves'); + const [vaultResult, capabilitiesResult, gamesResult] = await Promise.allSettled([ + window.pork.ecosystemSaveVault(), + window.pork.ecosystemSaveRestoreCapabilities(), + window.pork.listGames({}), + ]); + const allSnapshots = vaultResult.status === 'fulfilled' && Array.isArray(vaultResult.value) ? vaultResult.value : []; + const capabilities = capabilitiesResult.status === 'fulfilled' && capabilitiesResult.value + ? capabilitiesResult.value + : { automatedRestore: false, error: capabilitiesResult.reason?.message || 'Restore capabilities could not be verified.' }; + const snapshots = gameContext ? allSnapshots.filter(item => item.titleId === gameContext) : allSnapshots; + const gamesById = new Map((gamesResult.status === 'fulfilled' ? gamesResult.value : []).map(game => [game.game_id, game])); + _ecosystemSaveCapabilities = capabilities; + $('ecosystem-save-restore-safety').innerHTML = capabilities.automatedRestore + ? 'Protected PS5 restore availablePPSA archives only. Mandatory SHA-256 and CRC dry run, target-user validation, pre-restore snapshot, exact confirmation and byte-for-byte post-check. Resign, forced import and automatic rollback remain disabled.' + : `Restore remains blocked${escHtml(localizedEcosystemError(capabilities.reason || capabilities.error || 'Garlic does not prove the required import capabilities.'))}`; + const vaultFailure = vaultResult.status === 'rejected' + ? `
    Local vault unavailable

    ${escHtml(localizedEcosystemError(vaultResult.reason?.message || 'The local snapshot index could not be loaded.'))}

    ` + : ''; + $('ecosystem-save-vault').innerHTML = `${vaultFailure}${gameContext ? `
    Game workspace ${escHtml(gameContext)}Only snapshots for this game are shown. Refresh the page to see the complete Save Vault.
    ` : ''}${snapshots.length ? snapshots.map(item => { + const game = gamesById.get(item.titleId); + const title = game?.prospero_name || game?.title || item.titleId; + return `
    ${game?.prospero_icon_url ? `` : ''}
    ${escHtml(title)}${escHtml(item.titleId)} · ${ecosystemDate(item.createdAt)} · ${ecosystemBytes(item.bytes)}${item.purpose ? ` · ${escHtml(item.purpose)}` : ''}${escHtml(String(item.sha256).slice(0, 18))}…
    `; + }).join('') : '
    No snapshots yetCreate a verified snapshot after console saves have been discovered.
    '}`; +} + +function renderTimeline(result) { + const state = result.state || {}; + const options = result.options || {}; + $('ecosystem-timeline-persist').checked = options.enabled === true; + $('ecosystem-timeline-retention').value = options.retentionDays || 14; + $('ecosystem-timeline-max').value = options.maxEvents || 5000; + $('ecosystem-timeline-state').innerHTML = `

    ${state.connected ? `Connected on port ${state.port} since ${ecosystemDate(state.startedAt)}` : state.error ? `Not connected: ${escHtml(state.error)}` : 'Not connected.'} · ${result.filtered || 0}/${result.total || 0} events · persistence ${options.enabled ? 'on' : 'off'}

    `; + $('ecosystem-timeline-events').innerHTML = (result.events || []).slice().reverse().map(event => `
    ${escHtml(event.source)}${escHtml(event.message)}
    `).join('') || '

    No events yet.

    '; +} + +function renderGameUpdateJobs(jobs) { + $('game-updates-jobs').innerHTML = jobs.length ? jobs.map(job => `
    ${escHtml(job.manifest.gameId)} · ${escHtml(job.manifest.filename)}${escHtml(job.status)} · ${Number(job.progress || 0)}% · ${ecosystemBytes(job.bytesDownloaded || job.manifest.originalFileSize)}${job.error ? `${escHtml(job.error)}` : job.remotePath ? `${escHtml(job.remotePath)}` : job.localPath ? `${escHtml(job.localPath)}` : ''}
    ${ecosystemStatus(job.status === 'completed' ? 'compatible' : job.status === 'failed' ? 'warning' : 'unknown', job.status)}${['queued', 'downloading', 'verifying'].includes(job.status) ? '' : ''}${job.status === 'completed' ? '' : ''}${!['queued', 'downloading', 'verifying', 'staging'].includes(job.status) ? '' : ''}
    `).join('') : '

    No game update jobs yet.

    '; +} + +async function loadGameUpdates() { + const [catalog, jobs] = await Promise.all([window.pork.gameUpdatesCatalog(), window.pork.gameUpdatesJobs()]); + const discovered = catalog.games.filter(game => game.discovery?.status === 'available').length; + const updateStateLabel = state => window.t(`ecosystem.gameUpdates.state.${state}`, state); + const evidenceRows = catalog.games.map(game => { + const locations = (game.locations || []).map(location => { + const observed = location.observedAt ? new Date(location.observedAt).toLocaleString() : window.t('ecosystem.gameUpdates.evidenceUnknown', 'evidence time unknown'); + const label = location.volumeLabel || location.location || location.volumeId || window.t('ecosystem.gameUpdates.locationUnknown', 'unknown location'); + return `${escHtml(label)} · ${escHtml(location.version || '?')} · ${escHtml(location.source || 'unknown')} (${escHtml(location.confidence || 'unknown')}) · ${escHtml(observed)} · ${escHtml(updateStateLabel(location.status))}`; + }).join(''); + return `
    ${escHtml(game.title)}${escHtml(game.gameId)} · ${escHtml(game.latestVersion || '?')} · ${escHtml(game.sourceEvidence?.source || 'unknown')}${game.discovery?.reason ? ` · ${escHtml(game.discovery.reason)}` : ''}${locations || `${escHtml(window.t('ecosystem.gameUpdates.noConfirmedLocations', 'No confirmed console locations'))}`}
    ${ecosystemStatus(game.updateAvailable ? 'warning' : game.status === 'current' ? 'compatible' : 'unknown', updateStateLabel(game.status))}
    `; + }).join(''); + $('game-updates-summary').innerHTML = `
    ${[[window.t('ecosystem.gameUpdates.gameCount', 'Games'), catalog.stats.total], [window.t('ecosystem.gameUpdates.officialManifests', 'Official manifests'), discovered], [window.t('ecosystem.gameUpdates.available', 'Possible updates'), catalog.stats.updates]].map(([label, value]) => `
    ${escHtml(value)}${escHtml(label)}
    `).join('')}
    ${escHtml(window.t('ecosystem.gameUpdates.discoveryEvidence', 'Library discovery evidence'))} · ${catalog.games.length}
    ${evidenceRows}
    `; + $('game-updates-game').innerHTML = `` + catalog.games.map(game => ``).join(''); + renderGameUpdateJobs(jobs); +} + +function renderGameUpdatePlan(plan) { + _gameUpdatePlan = plan; + $('game-updates-plan').innerHTML = `${escHtml(plan.manifest.gameId)} · ${escHtml(plan.manifest.filename)}${ecosystemBytes(plan.manifest.originalFileSize)} · ${plan.manifest.pieces.length} part(s) · free space ${ecosystemBytes(plan.availableBytes)} · ${plan.manifest.packageDigest ? 'complete SHA-256 available' : 'no complete SHA-256 in manifest'}`; + $('game-updates-download-confirm')?.addEventListener('click', async click => { + if (!await showConfirm('Download this official Sony package to Chimera backup storage? Every part and the complete package will be verified. Nothing is installed on the PS5.')) return; + try { await ecosystemBusy(click.currentTarget, () => window.pork.gameUpdatesDownload(plan.token)); _gameUpdatePlan = null; showToast('Game update job started.', 'ok'); await loadGameUpdates(); } + catch (error) { showToast(error.message, 'error', 10000); } + }); +} + +function renderReadonlyFiles(targetId, packet, kind) { + const rows = packet.roots?.length ? packet.roots : packet.items || []; + $(targetId).innerHTML = rows.length ? rows.map(item => `
    ${item.isDirectory ? '📁' : '📄'} ${escHtml(item.name)}${item.isDirectory ? escHtml(item.mode || 'folder') : ecosystemBytes(item.size)}${item.mtime ? ` · ${ecosystemDate(item.mtime * 1000)}` : ''}
    `).join('') : '

    This folder is empty.

    '; + const pathTarget = kind === 'smb' ? 'websrv-smb-path' : 'websrv-files-path'; + $(pathTarget).innerHTML = `${escHtml(packet.path || (kind === 'smb' ? 'SMB-root' : '/'))}Read-only · ${rows.length} item(s)${packet.parent !== null && packet.parent !== undefined ? `` : ''}`; +} + +async function loadWebsrvFiles(remotePath = _websrvFsPath) { + const packet = await window.pork.ecosystemWebsrvFiles(remotePath); + _websrvFsPath = packet.path; + renderReadonlyFiles('websrv-files-list', packet, 'fs'); +} + +async function loadWebsrvSmb(smbPath = _websrvSmbPath) { + const sourceId = $('websrv-smb-source').value; + if (!sourceId) return; + const packet = await window.pork.ecosystemWebsrvSmb(sourceId, smbPath); + _websrvSmbPath = packet.path; + renderReadonlyFiles('websrv-smb-list', packet, 'smb'); +} + +async function loadFilesTab() { + const sources = (await window.pork.ecosystemSources()).filter(source => source.protocol === 'smb'); + const select = $('websrv-smb-source'); const selected = select.value; + select.innerHTML = '' + sources.map(source => ``).join(''); + if (sources.some(source => source.id === selected)) select.value = selected; + if (!sources.length) { + $('websrv-smb-path').innerHTML = 'No SMB sources configuredAdd a reviewed network source before browsing it through the PS5.'; + $('websrv-smb-list').innerHTML = '
    Connect a storage sourceSources are managed centrally so arbitrary hosts never appear here.
    '; + } + await loadWebsrvFiles(_websrvFsPath); +} + +async function loadLiveSession() { + const packet = await window.pork.ecosystemLiveSession(); + $('ecosystem-live-session').innerHTML = packet.online ? `
    Live telemetry available${escHtml(packet.branding || 'ps5debug-NG')} · ${escHtml(packet.host)}:${escHtml(packet.port)} · checked ${ecosystemDate(packet.checkedAt)}
    ${ecosystemStatus('online', 'Read-only')}
    ${[['Protocol', packet.protocol || '?'], ['Firmware', packet.firmware || '?'], ['Platform', packet.platform === 5 ? 'PS5' : packet.platform || '?']].map(([label, value]) => `
    ${escHtml(value)}${escHtml(label)}
    `).join('')}
    ${packet.foreground ? `
    Foreground application

    ${escHtml(packet.foreground.game?.title || packet.foreground.titleId)}

    ${escHtml(packet.foreground.titleId)} · v${escHtml(packet.foreground.version || '?')} · PID ${escHtml(packet.foreground.pid)}

    ${escHtml(packet.foreground.contentId || packet.foreground.processName)}
    ` : '
    Foreground application

    No game active

    The console service is online and ready to report the next foreground title.

    '}
    ✓ Only five reviewed read-only commands are registered. Memory, attach, kernel, notification and reboot are not exposed.
    ` : `
    1
    ps5debug-NG 1.3.0 installedVerified official release staged through Payload Manager
    2
    Start payload when neededPort ${escHtml(packet.port)} must be reachable
    3
    Read sessionUse the button above to retry
    Live telemetry unavailable

    ${escHtml(packet.error || 'Service offline.')}

    `; +} + +function remotePlaySelectedWorker(packet) { + return (packet.workers || []).find(worker => worker.id === ($('remote-play-worker')?.value || packet.selectedWorkerId)) || null; +} + +function renderRemotePlay(packet, companion) { + _remotePlayPacket = packet; + const discovery = packet.lastDiscovery || {}; + const workers = packet.workers || []; + const selectedId = $('remote-play-worker')?.value || packet.selectedWorkerId || ''; + const selected = workers.find(worker => worker.id === selectedId) || workers[0] || null; + const health = selected?.streamHealth || null; + const clientName = packet.clientNickname || discovery.console?.name || ''; + const registered = Boolean(selected?.registeredHosts?.includes(clientName)); + const consoleReady = discovery.state === 'ready'; + const consoleStandby = discovery.state === 'standby'; + const checks = [ + { label: 'PS5', ok: consoleReady, partial: consoleStandby, value: consoleReady ? window.t('remotePlay.state.ready', 'Ready') : consoleStandby ? window.t('remotePlay.state.standby', 'Rest mode') : discovery.online ? discovery.state : window.t('remotePlay.state.notDiscovered', 'Not discovered') }, + { label: 'Worker', ok: selected?.online, value: selected?.online ? `${selected.label} online` : selected ? 'Offline' : window.t('remotePlay.state.notPaired', 'Not paired') }, + { label: 'chiaki-ng', ok: selected?.client?.installed, value: selected?.client?.installed ? `${selected.client.version || window.t('remotePlay.state.installed', 'Installed')}${selected.client.managed ? ` · ${window.t('remotePlay.state.managed', 'Chimera-managed')}` : ''}` : window.t('remotePlay.state.notFound', 'Not found') }, + { label: window.t('remotePlay.registration', 'Registration'), ok: registered, value: registered ? clientName : clientName ? window.tf('remotePlay.registrationMissing', '{name} is not registered locally', { name: clientName }) : window.t('remotePlay.consoleNameMissing', 'Console name missing') }, + ]; + $('remote-play-readiness').innerHTML = checks.map(check => `
    ${escHtml(check.label)}${escHtml(check.value)}
    `).join(''); + + if (document.activeElement !== $('remote-play-nickname')) $('remote-play-nickname').value = packet.nickname || ''; + if (document.activeElement !== $('remote-play-client-nickname')) $('remote-play-client-nickname').value = clientName; + $('remote-play-key').value = ''; + $('remote-play-display-mode').value = packet.displayMode || 'fullscreen'; + $('remote-play-worker').innerHTML = workers.length ? workers.map(worker => ``).join('') : ``; + $('remote-play-worker').value = selected?.id || ''; + $('remote-play-status').innerHTML = `${escHtml(packet.registrationKeyConfigured ? window.t('remotePlay.profile.secured', 'Wake profile secured') : window.t('remotePlay.profile.keyMissing', 'Wake key missing'))}${escHtml(packet.nickname)} · ${escHtml(packet.host || window.t('remotePlay.profile.noHost', 'no PS5 host'))} · UDP ${packet.port} · ${escHtml(window.t('remotePlay.profile.sessionPorts', 'session ports'))} ${(packet.sessionPorts || []).join(', ')}${escHtml(window.t('remotePlay.profile.registrationKey', 'registration key'))} ${escHtml(packet.registrationKeyConfigured ? window.t('remotePlay.profile.keyEncrypted', 'AES-256-GCM encrypted and never returned') : window.t('remotePlay.profile.keyWakeOnly', 'needed only to wake from rest mode'))}`; + + $('remote-play-workers').innerHTML = workers.length ? workers.map(worker => `
    ${escHtml(worker.label)}${worker.online ? `${escHtml(worker.hostname || worker.platform || 'worker')} · ${worker.client?.installed ? `chiaki-ng ${escHtml(worker.client.version || 'found')}${worker.client.managed ? ' · managed' : ''}` : 'chiaki-ng missing'}` : worker.enrollmentPending ? 'Waiting for installation and first pairing' : `Offline${worker.lastSeenAt ? ` · last ${ecosystemDate(worker.lastSeenAt)}` : ''}`}
    ${ecosystemStatus(worker.online ? 'online' : worker.enrollmentPending ? 'unknown' : 'offline', worker.online ? 'Online' : worker.enrollmentPending ? 'Install' : 'Offline')}
    `).join('') : '
    No local worker yet

    Download the package above on the device where you want to play.

    '; + + const healthLabel = health?.status === 'healthy' ? window.t('remotePlay.health.healthy', 'Healthy') : health?.status === 'degraded' ? window.t('remotePlay.health.degraded', 'Degraded') : window.t('remotePlay.health.attention', 'Attention'); + const healthTone = health?.status === 'healthy' ? 'online' : health?.status === 'degraded' ? 'error' : 'warning'; + const healthMarkup = health ? `
    Last stream · ${escHtml(healthLabel)}${escHtml(health.resolution || 'resolution unknown')} · ${escHtml(health.decoder || 'decoder unknown')}${health.rttMs != null ? ` · RTT ${escHtml(health.rttMs)} ms` : ''} · ${ecosystemDate(health.checkedAt)}Packet/FEC errors ${escHtml(health.packetErrors)} · duplicates ${escHtml(health.duplicateUnits)} · decoder buffer drops ${escHtml(health.decoderDrops)}${ecosystemStatus(healthTone, healthLabel)}
    ` : ''; + const workflowCopy = { + 'Console ontdekken': [window.t('remotePlay.workflow.discovery', 'Discover console'), window.t('remotePlay.workflow.discoveryDetail', 'LAN discovery through the upstream PS5 protocol on UDP 9302.')], + Wakeprofiel: [window.t('remotePlay.workflow.wakeProfile', 'Wake profile'), window.t('remotePlay.workflow.wakeDetail', 'Wake uses only the encrypted rp_regist_key.')], + 'Lokale worker': [window.t('remotePlay.workflow.worker', 'Local worker'), window.t('remotePlay.workflow.workerDetail', 'The HMAC-paired worker manages and starts only a hash-verified chiaki-ng client.')], + 'chiaki-ng geregistreerd': [window.t('remotePlay.workflow.registered', 'chiaki-ng registered'), window.t('remotePlay.workflow.registeredDetail', 'The PSN Account ID and Link Device PIN remain only in the local chiaki-ng configuration.')], + }; + $('remote-play-companion').innerHTML = `
    ${(companion.workflow || []).map(step => { + const translated = workflowCopy[step.id] || [step.id, step.detail]; + return `
    ${escHtml(translated[0])}${escHtml(translated[1])}
    ${ecosystemStatus(step.ready ? 'online' : 'unknown', step.ready ? window.t('remotePlay.state.ready', 'Ready') : window.t('remotePlay.state.configure', 'Configure'))}
    `; + }).join('')}
    ${healthMarkup}
    ${escHtml(window.t('remotePlay.native.title', 'Why native?'))}${escHtml(window.t('remotePlay.native.reason', 'Video, audio, controller and haptics run natively on the device where chiaki-ng and the controller are present; Chimera orchestrates and monitors the session.'))}${escHtml(window.t('remotePlay.native.detail', 'The worker has no shell, receives no PS5 registration key and can perform only fixed chiaki-ng actions.'))}
    `; + + const jobStatuses = { queued: window.t('remotePlay.jobs.queued', 'Queued'), dispatched: window.t('remotePlay.jobs.dispatched', 'Dispatched…'), completed: window.t('remotePlay.jobs.completed', 'Completed'), failed: window.t('remotePlay.jobs.failed', 'Failed'), expired: window.t('remotePlay.jobs.expired', 'Expired'), cancelled: window.t('remotePlay.jobs.cancelled', 'Cancelled') }; + $('remote-play-jobs').innerHTML = packet.jobs?.length ? `
    ${packet.jobs.map(job => `
    ${escHtml(job.action)}${ecosystemDate(job.createdAt)}${job.result?.message ? ` · ${escHtml(job.result.message)}` : job.error ? ` · ${escHtml(job.error)}` : ''}
    ${ecosystemStatus(job.status === 'completed' ? 'online' : job.status === 'failed' ? 'error' : job.status === 'expired' ? 'offline' : 'unknown', jobStatuses[job.status] || job.status)}
    `).join('')}
    ` : `

    ${escHtml(window.t('remotePlay.sessions.empty', 'No Remote Play action has run yet.'))}

    `; + + const blockers = []; + if (!selected?.online) blockers.push('worker offline'); + if (!selected?.client?.installed) blockers.push(window.t('remotePlay.blocker.clientMissing', 'chiaki-ng missing')); + if (!registered) blockers.push(window.t('remotePlay.blocker.notRegistered', 'console not registered locally')); + if (!consoleReady && !(consoleStandby && packet.registrationKeyConfigured)) blockers.push(window.t('remotePlay.blocker.notReady', 'PS5 not ready')); + $('remote-play-start').disabled = blockers.length > 0; + $('remote-play-start-hint').textContent = blockers.length + ? window.tf('remotePlay.blocker.prefix', 'Still needed: {items}.', { items: blockers.join(' · ') }) + : consoleStandby + ? window.t('remotePlay.ready.wakeFirst', 'Chimera will confirm wake first and then start the local stream.') + : window.t('remotePlay.ready.start', 'All checks passed; the stream opens locally on the selected worker.'); + $('remote-play-wake').disabled = !packet.registrationKeyConfigured || consoleReady; + $('remote-play-diagnose').disabled = !selected?.online; + $('remote-play-open-client').disabled = !selected?.online || !selected?.client?.installed; + $('remote-play-install-client').disabled = !selected?.online || selected?.client?.installed; + $('remote-play-update-client').disabled = !selected?.online || !selected?.client?.installed; + $('remote-play-stop').disabled = !selected?.online || !selected?.session?.active; +} + +async function loadRemotePlay({ discover = false, schedule = true } = {}) { + if (discover) await window.pork.remotePlayDiscover().catch(() => null); + const [packet, companion] = await Promise.all([window.pork.remotePlayStatus(), window.pork.remotePlayCompanion()]); + renderRemotePlay(packet, companion); + clearTimeout(_remotePlayPollTimer); + if (schedule && _ecosystemActiveTab === 'remote-play') _remotePlayPollTimer = setTimeout(() => loadRemotePlay({ schedule: true }).catch(error => showToast(error.message, 'error')), 3_000); +} + +async function loadItemzflow() { + _itemzflowPacket = await window.pork.ecosystemItemzflowStatus(); + const packet = _itemzflowPacket; + $('itemzflow-summary').innerHTML = `
    ${[['Logs', `${packet.health?.available || 0}/${packet.logs?.length || 0}`], ['Errors', packet.health?.errors || 0], ['Warnings', packet.health?.warnings || 0], ['Games', packet.games?.installed || 0], ['Progress', packet.latestProgress?.progress != null ? `${packet.latestProgress.progress}%` : 'None active']].map(([label, value]) => `
    ${escHtml(value)}${escHtml(label)}
    `).join('')}
    ${packet.error ? `

    ${escHtml(localizedEcosystemError(packet.error))}

    ` : `

    ${packet.lines} lines from ${packet.health?.available || 0} logs · ${ecosystemDate(packet.checkedAt)}

    `}
    ${(packet.logs || []).map(log => `
    ${escHtml(log.label)}${escHtml(log.path)} · ${log.lines || 0} lines
    ${ecosystemStatus(log.online ? (log.errorCount ? 'warning' : 'online') : 'offline', log.online ? `${log.errorCount || 0} errors · ${log.warningCount || 0} warnings` : 'Not present')}
    `).join('')}
    ${packet.nfsSources?.length ? `
    NFS profiles${packet.nfsSources.map(source => `${escHtml(source.label)} · ${escHtml(source.host)}:${source.port}${escHtml(source.path)}`).join(' · ')}
    ` : ''}`; + const selected = $('itemzflow-log-select').value; + $('itemzflow-log-select').innerHTML = '' + (packet.logs || []).map(log => ``).join(''); + if ((packet.logs || []).some(log => log.id === selected)) $('itemzflow-log-select').value = selected; + renderItemzflowEvents(); +} + +function renderItemzflowEvents() { + if (!_itemzflowPacket) return; + const source = $('itemzflow-log-select').value; + const level = $('itemzflow-level').value; + const jobsOnly = $('itemzflow-jobs-only').checked; + const pool = jobsOnly ? _itemzflowPacket.events || [] : _itemzflowPacket.recentLogs || []; + const events = pool.filter(event => (source === 'all' || event.source === source) && (level === 'all' || event.level === level)); + $('itemzflow-events').innerHTML = events.map(event => `
    ${escHtml(event.sourceLabel || event.source)}${escHtml(event.type)} · ${escHtml(event.message)}${event.progress != null ? `${event.progress}%` : `${escHtml(event.level)}`}
    `).join('') || '

    No events match these filters.

    '; +} + +async function loadLinuxLab() { + const packet = await window.pork.ecosystemLinuxLab(); + const state = packet.status === 'compatible' ? 'Firmware explicitly supported' : packet.status === 'incompatible' ? 'Firmware unsupported' : 'Firmware unknown'; + $('ecosystem-linux-summary').innerHTML = `
    ${[['Status', state], ['Profile', packet.firmware || 'Not entered'], ['Models', packet.supportedModels.join(' + ')], ['Execution', 'Blocked']].map(([label, value]) => `
    ${escHtml(value)}${escHtml(label)}
    `).join('')}

    Source reviewed ${escHtml(packet.sourceReviewedAt)} · supported firmware: ${packet.supportedFirmwares.map(escHtml).join(', ')}

    `; + $('ecosystem-linux-checks').innerHTML = (packet.requirements || []).map(check => `
    ${escHtml(check.label)}${escHtml(check.detail)}
    ${ecosystemStatus(check.status, check.status === 'compatible' ? 'Satisfied' : check.status === 'incompatible' ? 'Incompatible' : check.status === 'warning' ? 'Disable manually' : 'Check manually')}
    `).join(''); + $('ecosystem-linux-components').innerHTML = (packet.components || []).map(component => `
    ${escHtml(component.name)}${escHtml(component.release?.tag || 'release unknown')} · ${escHtml(component.risk)}
    `).join(''); + $('ecosystem-linux-policy').innerHTML = 'All dangerous actions are intentionally absentNo download, payload sender, exploit, boot, block-device write or automatic HEN/kstuff change. Use the official upstream guide outside Chimera and only after every preflight condition has been verified manually.'; +} + +async function loadTimeline() { renderTimeline(await window.pork.ecosystemTimeline({ search: $('ecosystem-timeline-search')?.value || '', level: $('ecosystem-timeline-level')?.value || '', limit: 1000 })); } + +let _platformLabState = { artifacts: [], launchPlan: null }; +function platformLabText(key, fallback) { return window.t(`platformLab.${key}`, fallback); } + +function platformLabError(id, error) { + const target = $(id); + if (target) target.innerHTML = `
    ${escHtml(platformLabText('unavailable', 'Unavailable'))}
    ${escHtml(error?.message || String(error))}
    `; +} + +function renderPlatformBindings(data) { + $('platform-bindings').innerHTML = (data.bindings || []).map(binding => `
    ${escHtml(binding.label)}
    ${escHtml(binding.repository)} · ${escHtml(binding.latestTag || platformLabText('notChecked', 'not checked'))} · ${binding.assets?.length || 0} assets
    ${escHtml(binding.state)}${binding.latestTag ? `` : ''}
    `).join('') || `

    ${escHtml(platformLabText('empty', 'No results.'))}

    `; +} + +function renderAutoloader(data) { + const profiles = [...(data.presets || []), ...(data.profiles || [])]; + _platformLabState.profiles = profiles; + $('platform-autoloader-profile').innerHTML = profiles.map(profile => ``).join(''); + const selected = profiles[0] || { id: 'custom-profile', name: 'Custom profile', entrypoints: ['y2jb'], titleIds: [], payloads: ['pldmgr.elf'], firmware: null, youtubeVersion: null }; + $('platform-profile-json').value = JSON.stringify(selected, null, 2); + $('platform-autoloader-output').innerHTML = `
    ${escHtml(platformLabText('autoloader.safe', 'Profiles are preview-only; no USB or console writes are performed.'))}
    `; +} + +function renderAutoloaderPreview(data) { + $('platform-autoloader-output').innerHTML = `
    ${escHtml(data.profile.name)}
    ${escHtml(data.directory)} · ${escHtml((data.profile.entrypoints || []).join(', '))}
    ${escHtml(data.blocked ? platformLabText('blocked', 'blocked') : platformLabText('previewOnly', 'preview only'))}
    ${escHtml(data.autoloadText)}
    ${(data.compatibility || []).map(item => `
    ${escHtml(item.message)}
    ${escHtml(item.state)}
    `).join('')}${(data.warnings || []).map(warning => `
    ${escHtml(warning)}
    `).join('')}`; +} + +function discoveryCount(value) { return Array.isArray(value) ? value.length : value && typeof value === 'object' ? Object.keys(value).length : 0; } + +function renderPlatformWebsrv(data) { + $('platform-websrv').innerHTML = `
    ${discoveryCount(data.mdns)}mDNS
    ${data.capabilities?.smb ? '✓' : '−'}SMB
    ${data.capabilities?.filesystem ? '✓' : '−'}Filesystem
    ${(data.errors || []).map(error => `
    ${escHtml(error)}
    `).join('')}${(data.blockedExternal || []).map(item => `
    ${escHtml(item.capability)}: ${escHtml(item.reason)}
    `).join('')}
    ${escHtml(platformLabText('readOnly', 'Read-only discovery'))}
    `; +} + +function renderRemoteIntelligence(data) { + const workers = data.workers || []; + $('platform-remote').innerHTML = `
    ${data.counts?.online || 0}${escHtml(platformLabText('online', 'online'))}
    ${data.counts?.updates || 0}${escHtml(platformLabText('updates', 'updates'))}
    ${data.counts?.degraded || 0}${escHtml(platformLabText('attention', 'attention'))}
    ${workers.map(worker => `
    ${escHtml(worker.name || worker.id)}
    ${escHtml(worker.version || platformLabText('unknownVersion', 'unknown version'))} · ${escHtml((worker.recommendations || []).join(', ') || platformLabText('healthy', 'healthy'))}
    ${escHtml(worker.updateState)}
    `).join('') || `

    ${escHtml(platformLabText('noWorkers', 'No Remote Play workers paired.'))}

    `}`; +} + +function renderShadps4Roots(data) { + $('platform-shadps4-root').innerHTML = `${(data.roots || []).map(root => ``).join('')}`; +} + +function renderShadps4Scan(data) { + if (data.status === 'not-configured') { $('platform-shadps4').innerHTML = `

    ${escHtml(platformLabText('selectRootHint', 'Select a semantic root to scan.'))}

    `; return; } + const entries = [...(data.copies || []), ...(data.archives || [])]; + $('platform-shadps4').innerHTML = `
    ${(data.copies || []).length}${escHtml(platformLabText('copies', 'copies'))}
    ${(data.archives || []).length}${escHtml(platformLabText('archiveCandidates', 'archive candidates'))}
    ${entries.slice(0, 50).map(item => `
    ${escHtml(item.titleId || item.name)}
    ${escHtml(item.relativePath || item.name)} · ${escHtml(item.format)}
    ${escHtml(platformLabText('emulatorCopy', 'emulator copy'))}${item.format === 'directory' ? `` : ''}
    `).join('')}
    ${escHtml(platformLabText('notConsoleInstall', 'These records never imply a console installation.'))}
    `; +} + +function renderArtifactRoots(data) { + const options = `${(data.roots || []).map(root => ``).join('')}`; + $('platform-artifact-root').innerHTML = options; + $('platform-stage-root').innerHTML = options; +} + +function renderArtifacts(data) { + _platformLabState.artifacts = data.artifacts || []; + $('platform-artifacts').innerHTML = _platformLabState.artifacts.map(item => `
    ${item.lifecycleState === 'hashed' ? `` : `${escHtml(item.lifecycleState)}`}
    `).join('') || `

    ${escHtml(platformLabText('noArtifacts', 'No inspected artifacts.'))}

    `; +} + +function renderContracts(data) { + $('platform-contracts').innerHTML = (data.contracts || []).map(item => `
    ${escHtml(item.label)}
    ${escHtml(item.mode)}${item.missing.length ? ` · ${escHtml(item.missing.join(', '))}` : ''}
    ${escHtml(item.state)}
    `).join(''); +} + +function renderNotifications(data) { + $('platform-notifications').innerHTML = (data.items || []).map(item => `
    ${escHtml(item.category)}
    ${escHtml(item.message)}
    ${escHtml(item.severity)}
    `).join('') || `

    ${escHtml(platformLabText('noNotifications', 'No current recommendations.'))}

    `; +} + +function renderOperations(data) { + $('platform-operations').innerHTML = (data.jobs || []).slice(0, 10).map(job => `
    ${escHtml(job.type)}
    ${escHtml(job.error || job.updatedAt)}
    ${escHtml(job.state)} · ${job.progress}%
    `).join('') || `

    ${escHtml(platformLabText('noOperations', 'No background operations.'))}

    `; +} + +function renderLaunchRoots(data) { + $('platform-shadps4-exe-root').innerHTML = `${(data.roots || []).map(root => ``).join('')}`; +} + +function renderLaunchProbe(data) { + $('platform-shadps4-launch').innerHTML = `
    ${data.available ? `${escHtml(data.executableLabel)} · ${escHtml(platformLabText('ready', 'ready'))}` : escHtml(platformLabText('launcherNotConfigured', 'Launcher not configured.'))} · shell=false
    `; +} + +function renderSearch(data) { + $('platform-search-results').innerHTML = (data.results || []).map(item => `
    ${escHtml(item.title)}
    ${escHtml(item.kind)} · ${escHtml(item.detail)}
    `).join('') || `

    ${escHtml(platformLabText('empty', 'No results.'))}

    `; +} + +async function loadPlatformLab() { + const jobs = [ + ['platform-bindings', window.pork.platformBindings(), renderPlatformBindings], + ['platform-autoloader-output', window.pork.unifiedAutoloaderList(), renderAutoloader], + ['platform-websrv', window.pork.platformWebsrvDiscover(), renderPlatformWebsrv], + ['platform-remote', window.pork.remotePlayIntelligence(), renderRemoteIntelligence], + ['platform-shadps4', window.pork.shadps4Roots(), renderShadps4Roots], + ['platform-shadps4', window.pork.shadps4Scan(), renderShadps4Scan], + ['platform-artifacts', window.pork.platformArtifactRoots(), renderArtifactRoots], + ['platform-artifacts', window.pork.platformArtifacts(), renderArtifacts], + ['platform-contracts', window.pork.platformContracts(), renderContracts], + ['platform-notifications', window.pork.platformNotifications(), renderNotifications], + ['platform-operations', window.pork.platformOperations(), renderOperations], + ['platform-shadps4-launch', window.pork.shadps4LaunchRoots(), renderLaunchRoots], + ['platform-shadps4-launch', window.pork.shadps4LaunchProbe(), renderLaunchProbe], + ]; + const results = await Promise.allSettled(jobs.map(job => job[1])); + results.forEach((result, index) => result.status === 'fulfilled' ? jobs[index][2](result.value) : platformLabError(jobs[index][0], result.reason)); +} + +async function loadEcosystemTab(tab) { + if (tab === 'overview') return loadEcosystemOverview(); + if (tab === 'components') return loadEcosystemComponents(); + if (tab === 'updates') return loadEcosystemUpdates(); + if (tab === 'game-updates') return loadGameUpdates(); + if (tab === 'etahen') return loadEtaHen(); + if (tab === 'live') return loadLiveSession(); + if (tab === 'remote-play') return loadRemotePlay({ discover: !_remotePlayPacket }); + if (tab === 'files') return loadFilesTab(); + if (tab === 'homebrew') return loadHomebrew(); + if (tab === 'sources') return loadSources(); + if (tab === 'plugins') return loadPlugins(); + if (tab === 'cheats') return loadCheatsV2(); + if (tab === 'saves') return loadSaveVault(); + if (tab === 'itemzflow') return loadItemzflow(); + if (tab === 'platform-lab') return loadPlatformLab(); + if (tab === 'research') return loadLinuxLab(); + if (tab === 'timeline') return loadTimeline(); +} + +function activateEcosystemTab(tab) { + _ecosystemActiveTab = tab; + document.querySelectorAll('.ecosystem-tab').forEach(button => { + const active = button.dataset.ecosystemTab === tab; + button.classList.toggle('active', active); + button.setAttribute('aria-selected', String(active)); + button.tabIndex = active ? 0 : -1; + }); + document.querySelectorAll('.ecosystem-panel').forEach(panel => { const active = panel.dataset.ecosystemPanel === tab; panel.classList.toggle('active', active); panel.hidden = !active; }); + loadEcosystemTab(tab).catch(error => showToast(error.message, 'error')); +} + +function navigateEcosystemTab(tab) { + const route = window.ChimeraNavigationContract?.routes?.find(item => item.page === 'ecosystem' && item.tab === tab); + if (route && typeof window.chimeraNavigate === 'function') return window.chimeraNavigate(route.id); + return activateEcosystemTab(tab); +} + +function bindEcosystemEvents() { + document.querySelectorAll('.ecosystem-tab').forEach(button => button.addEventListener('click', () => navigateEcosystemTab(button.dataset.ecosystemTab))); + $('ecosystem-refresh').addEventListener('click', event => ecosystemBusy(event.currentTarget, () => loadEcosystemTab(_ecosystemActiveTab)).catch(error => showToast(error.message, 'error'))); + $('ecosystem-health-run').addEventListener('click', event => ecosystemBusy(event.currentTarget, async () => { + await window.pork.ecosystemHealthRun(true); + await loadEcosystemOverview(); + showToast('Deep console health scan completed.', 'ok'); + }).catch(error => showToast(error.message, 'error', 9000))); + $('ecosystem-overview').addEventListener('click', async event => { + const alert = event.target.closest('.ecosystem-alert'); + if (!alert) return; + try { + if (event.target.closest('.ecosystem-alert-ack')) { + await ecosystemBusy(event.target.closest('button'), () => window.pork.ecosystemHealthAcknowledge(alert.dataset.id)); + await loadEcosystemOverview(); + } + const open = event.target.closest('.ecosystem-alert-open'); + if (open?.dataset.tab) navigateEcosystemTab(open.dataset.tab); + if (open?.dataset.page) navigate(open.dataset.page); + } catch (error) { showToast(error.message, 'error'); } + }); + $('ecosystem-overview').addEventListener('change', async event => { + if (event.target.id !== 'ecosystem-health-notifications') return; + try { + const packet = await window.pork.ecosystemHealthOptions(event.target.checked); + event.target.checked = Boolean(packet.notificationsEnabled); + showToast(packet.notificationsEnabled ? 'Health notifications enabled.' : 'Health notifications disabled.', 'ok'); + } catch (error) { event.target.checked = !event.target.checked; showToast(error.message, 'error'); } + }); + $('ecosystem-components-refresh').addEventListener('click', event => ecosystemBusy(event.currentTarget, async () => { await window.pork.ecosystemRefreshComponents(true); await loadEcosystemComponents(); showToast('Upstreamcomponenten bijgewerkt.', 'ok'); }).catch(error => showToast(error.message, 'error'))); + $('ecosystem-components-search').addEventListener('input', renderEcosystemComponents); + $('ecosystem-components-platform').addEventListener('change', renderEcosystemComponents); + $('ecosystem-components-risk').addEventListener('change', renderEcosystemComponents); + $('ecosystem-components').addEventListener('click', event => { const button = event.target.closest('.ecosystem-open-upstream'); if (button) window.pork.openShell(button.dataset.url); }); + $('ecosystem-updates-refresh').addEventListener('click', event => ecosystemBusy(event.currentTarget, async () => { + const result = await window.pork.ecosystemUpdatesRefresh(); + await loadEcosystemUpdates(); + showToast(`${result.stats?.total || 0} upstreamcomponenten en payloadcatalogi gesynchroniseerd.`, result.blocked ? 'info' : 'ok', 9000); + }).catch(error => showToast(error.message, 'error', 9000))); + $('ecosystem-watchdog-refresh').addEventListener('click', event => ecosystemBusy(event.currentTarget, loadEcosystemUpdates).catch(error => showToast(error.message, 'error', 10000))); + $('ecosystem-manager-refresh').addEventListener('click', event => ecosystemBusy(event.currentTarget, async () => { await window.pork.ecosystemPayloadRefresh(); await loadEcosystemUpdates(); showToast('Payload Manager sources refreshed; nothing was installed or started.', 'ok', 9000); }).catch(error => showToast(error.message, 'error', 10000))); + $('ecosystem-runtime-updates').addEventListener('click', async event => { + const pairButton = event.target.closest('.chimera-sentinel-pair'); + if (pairButton) { + const confirmation = await showExactConfirmation(window.t('ecosystem.sentinel.pairWarning', 'Sentinel pairs only briefly after payload start; the key stays in RAM.'), 'PAIR SENTINEL'); + if (!confirmation) return; + try { + await ecosystemBusy(pairButton, () => window.pork.chimeraSentinelPair({ confirmation, rotate: false })); + showToast('Sentinel 0.2 paired. The console keeps the key only until the payload stops.', 'ok', 10000); + await loadEcosystemUpdates(); + } catch (error) { showToast(error.message, 'error', 10000); } + return; + } + const button = event.target.closest('.ecosystem-runtime-install'); + const row = event.target.closest('[data-runtime-filename]'); + if (!button || !row) return; + const required = `UPDATE ${row.dataset.runtimeFilename}`; + const confirmation = await showExactConfirmation(`This installs only ${row.dataset.runtimeFilename} in Payload Manager and starts nothing.`, required); + if (!confirmation) return; + try { + const result = await ecosystemBusy(button, () => window.pork.ecosystemPayloadInstall({ filename: row.dataset.runtimeFilename, sourceId: row.dataset.runtimeSource, confirmation })); + showToast(`${result.filename} installed. The payload was not started.`, 'ok', 10000); + await loadEcosystemUpdates(); + } catch (error) { showToast(error.message, 'error', 10000); } + }); + $('ecosystem-supply-policy').addEventListener('submit', async event => { + event.preventDefault(); + try { + await ecosystemBusy(event.submitter, () => window.pork.ecosystemSupplyPolicySave({ officialSourcesOnly: $('ecosystem-supply-official').checked, requireHttps: $('ecosystem-supply-https').checked, requirePublishedHashForExecution: $('ecosystem-supply-hash').checked })); + showToast('Payload trust policy saved. Automatic execution remains disabled.', 'ok'); + } catch (error) { showToast(error.message, 'error'); } + }); + $('ecosystem-supply-audit').addEventListener('click', event => ecosystemBusy(event.currentTarget, async () => { + const report = await window.pork.ecosystemSupplyAudit(); + const blocked = report.assets.filter(asset => asset.downloaded && asset.issues.length); + $('ecosystem-supply-report').innerHTML = `${report.stats.verified} verified · ${report.stats.blocked} blocked${report.stats.assets} release assets from ${report.stats.sources} sources · checked ${ecosystemDate(report.checkedAt)}${blocked.length ? `
    ${blocked.slice(0, 30).map(asset => `

    ${escHtml(asset.source)} · ${escHtml(asset.name)} — ${asset.issues.map(escHtml).join(' ')}

    `).join('')}
    ` : '
    ✓ Every downloaded payload satisfies the active policy.
    '}`; + showToast(`Payload Vault: ${report.stats.verified} verified, ${report.stats.blocked} blocked.`, report.stats.blocked ? 'info' : 'ok', 10000); + }).catch(error => showToast(error.message, 'error', 10000))); + $('ecosystem-repository-copy').addEventListener('click', async () => { + const input = $('ecosystem-repository-url'); + try { + if (!navigator.clipboard?.writeText) throw new Error('Clipboard API unavailable'); + await navigator.clipboard.writeText(input.value); + showToast('Payload Manager source URL copied.', 'ok'); + } catch (_) { + input.focus(); input.select(); input.setSelectionRange(0, input.value.length); + const copied = typeof document.execCommand === 'function' && document.execCommand('copy'); + showToast(copied ? 'Payload Manager source URL copied.' : 'The URL is selected; copy it with Ctrl+C.', copied ? 'ok' : 'info'); + } + }); + $('ecosystem-payload-manager-open').addEventListener('click', () => navigate('jailbreak')); + $('game-updates-manifest-form').addEventListener('submit', async event => { + event.preventDefault(); + try { + const plan = await ecosystemBusy(event.submitter, () => window.pork.gameUpdatesAutoInspect($('game-updates-game').value, true)); + renderGameUpdatePlan(plan); + } catch (error) { _gameUpdatePlan = null; $('game-updates-plan').innerHTML = `Manifest afgekeurd${escHtml(error.message)}`; showToast(error.message, 'error', 10000); } + }); + $('game-updates-manual-inspect').addEventListener('click', async event => { + try { + const url = $('game-updates-manifest-url').value.trim(); + if (!url) throw new Error('Paste an official Sony JSON manifest URL first.'); + renderGameUpdatePlan(await ecosystemBusy(event.currentTarget, () => window.pork.gameUpdatesInspect(url, $('game-updates-game').value))); + } catch (error) { _gameUpdatePlan = null; $('game-updates-plan').innerHTML = `Manifest rejected${escHtml(error.message)}`; } + }); + $('game-updates-scan-all').addEventListener('click', async event => { + try { + const result = await ecosystemBusy(event.currentTarget, () => window.pork.gameUpdatesRefreshAll()); + showToast(`${result.available}/${result.total} official manifests available.`, result.failed ? 'info' : 'ok', 9000); + await loadGameUpdates(); + } catch (error) { showToast(error.message, 'error', 10000); } + }); + $('game-updates-refresh').addEventListener('click', event => ecosystemBusy(event.currentTarget, loadGameUpdates).catch(error => showToast(error.message, 'error'))); + $('game-updates-jobs').addEventListener('click', async event => { + const row = event.target.closest('.ecosystem-update-row'); const button = event.target.closest('button'); + if (!row || !button) return; + try { + if (button.classList.contains('game-update-cancel') && await showConfirm('Cancel this download and remove the incomplete file?')) await window.pork.gameUpdatesCancel(row.dataset.id); + if (button.classList.contains('game-update-stage')) { + if (!await showConfirm('Copy the fully verified package to /data/chimera-updates on the PS5? This installs or starts nothing.')) return; + const result = await ecosystemBusy(button, () => window.pork.gameUpdatesStage(row.dataset.id)); + showToast(`Package verified and copied to ${result.remotePath}; installation was not performed.`, 'ok', 12000); + } + if (button.classList.contains('game-update-handoff')) { + const result = await ecosystemBusy(button, () => window.pork.gameUpdatesHttpsHandoff(row.dataset.id)); + await navigator.clipboard.writeText(result.url); + showToast('Temporary verified HTTPS package URL copied; it expires in 30 minutes.', 'ok', 10000); + } + if (button.classList.contains('game-update-delete') && await showExactConfirmation('Permanently delete this job and local package file?', 'DELETE')) await window.pork.gameUpdatesDelete(row.dataset.id); + await loadGameUpdates(); + } catch (error) { showToast(error.message, 'error', 10000); } + }); + $('ecosystem-update-plan').addEventListener('click', async event => { + const row = event.target.closest('.ecosystem-update-row'); + if (!row) return; + const button = event.target.closest('button'); + try { + if (button?.classList.contains('ecosystem-version-save')) { + const version = row.querySelector('.ecosystem-version-input').value.trim(); + await ecosystemBusy(button, () => window.pork.ecosystemTrackVersion(row.dataset.id, version)); + await loadEcosystemUpdates(); + showToast(window.t(version ? 'ecosystem.payloadEvidence.manualSaved' : 'ecosystem.payloadEvidence.manualCleared', version ? 'Manual override saved.' : 'Manual tracking cleared.'), 'ok'); + } + if (button?.classList.contains('ecosystem-follow-automatic')) { + await ecosystemBusy(button, () => window.pork.ecosystemFollowAutomatic(row.dataset.id)); + await loadEcosystemUpdates(); + showToast(window.t('ecosystem.payloadEvidence.automaticSaved', 'Automatic evidence following enabled.'), 'ok'); + } + if (button?.classList.contains('ecosystem-update-prepare')) { + const asset = row.querySelector('.ecosystem-asset-select')?.value; + if (!await showConfirm('This only downloads the selected official release asset to Chimera and verifies available checksums. Nothing executes on the PS5. Continue?')) return; + const result = await ecosystemBusy(button, () => window.pork.ecosystemUpdatePrepare({ componentId: row.dataset.id, assetName: asset })); + await loadEcosystemUpdates(); + showToast(`Update voorbereid: ${result.file?.name || result.asset?.name || asset}${result.file?.verified || result.verified ? ' · checksum geverifieerd' : ''}.`, result.file?.verified || result.verified ? 'ok' : 'info', 9000); + } + } catch (error) { showToast(error.message, 'error', 9000); } + }); + $('ecosystem-etahen-reload').addEventListener('click', event => ecosystemBusy(event.currentTarget, loadEtaHen).catch(error => showToast(error.message, 'error'))); + $('ecosystem-etahen-preview').addEventListener('click', event => ecosystemBusy(event.currentTarget, async () => renderEtaDiff(await window.pork.ecosystemEtaHenPreview({ raw: $('ecosystem-etahen-raw').value, preset: $('ecosystem-etahen-preset').value || null, patch: etaPatchFromForm() }))).catch(error => showToast(error.message, 'error'))); + $('ecosystem-etahen-apply').addEventListener('click', async event => { + if (!_ecosystemEtaProposed || !await showExactConfirmation('Apply this etaHEN configuration? Chimera creates a restore point first and verifies the upload.', 'PAS ETAHEN TOE')) return; + try { await ecosystemBusy(event.currentTarget, () => window.pork.ecosystemEtaHenApply({ proposed: _ecosystemEtaProposed, expectedHash: _ecosystemEtaHash, label: $('ecosystem-etahen-preset').selectedOptions[0]?.textContent || 'Manual change' }, 'PAS ETAHEN TOE')); showToast('etaHEN configuration applied safely.', 'ok'); await loadEtaHen(); } catch (error) { showToast(error.message, 'error', 9000); } + }); + $('ecosystem-etahen-history').addEventListener('click', async event => { const row = event.target.closest('.ecosystem-history-row'); if (!row || !event.target.closest('.ecosystem-etahen-restore') || !await showExactConfirmation('Restore this etaHEN restore point?', 'HERSTEL')) return; try { await ecosystemBusy(event.target.closest('button'), () => window.pork.ecosystemEtaHenRestore(row.dataset.id)); showToast('etaHEN configuration restored.', 'ok'); await loadEtaHen(); } catch (error) { showToast(error.message, 'error'); } }); + $('ecosystem-live-refresh').addEventListener('click', event => ecosystemBusy(event.currentTarget, loadLiveSession).catch(error => showToast(error.message, 'error'))); + $('remote-play-profile').addEventListener('submit', async event => { + event.preventDefault(); + try { + await ecosystemBusy(event.submitter, () => window.pork.remotePlaySave({ nickname: $('remote-play-nickname').value, clientNickname: $('remote-play-client-nickname').value, workerId: $('remote-play-worker').value, displayMode: $('remote-play-display-mode').value, registrationKey: $('remote-play-key').value || undefined })); + $('remote-play-key').value = ''; + showToast('Remote Play profile saved safely.', 'ok'); + await loadRemotePlay(); + } catch (error) { showToast(error.message, 'error', 10000); } + }); + $('remote-play-worker').addEventListener('change', () => { if (_remotePlayPacket) loadRemotePlay().catch(error => showToast(error.message, 'error')); }); + $('remote-play-refresh').addEventListener('click', event => ecosystemBusy(event.currentTarget, () => loadRemotePlay({ schedule: true })).catch(error => showToast(error.message, 'error'))); + $('remote-play-discover').addEventListener('click', event => ecosystemBusy(event.currentTarget, async () => { const result = await window.pork.remotePlayDiscover(); await loadRemotePlay(); showToast(result.online ? `PS5 ${result.console?.name || ''} is ${result.state}.` : 'The PS5 did not answer LAN discovery.', result.online ? 'ok' : 'error'); }).catch(error => showToast(error.message, 'error'))); + $('remote-play-wake').addEventListener('click', async event => { + if (!await showExactConfirmation('This sends a wake packet to the PS5 and waits up to 30 seconds for the console to become ready.', 'WEK PS5')) return; + try { const result = await ecosystemBusy(event.currentTarget, () => window.pork.remotePlayWake()); showToast(result.ready ? (result.alreadyReady ? 'The PS5 was already ready.' : 'The PS5 is awake and ready.') : 'Wake packet sent, but the PS5 did not become ready in time.', result.ready ? 'ok' : 'error', 10000); await loadRemotePlay(); } catch (error) { showToast(error.message, 'error', 10000); } + }); + $('remote-play-start').addEventListener('click', async event => { + const worker = remotePlaySelectedWorker(_remotePlayPacket || {}); + if (!worker || !await showExactConfirmation(`Dit ${_remotePlayPacket?.lastDiscovery?.state === 'standby' ? 'wekt de PS5 en ' : ''}opent chiaki-ng op ${worker.label}.`, 'START REMOTE PLAY')) return; + try { + const result = await ecosystemBusy(event.currentTarget, () => window.pork.remotePlayStart({ workerId: worker.id, clientNickname: $('remote-play-client-nickname').value.trim(), displayMode: $('remote-play-display-mode').value, wakeIfNeeded: _remotePlayPacket?.lastDiscovery?.state === 'standby' })); + showToast(`Remote Play-job ${result.job.id.slice(0, 8)} staat klaar voor ${worker.label}.`, 'ok', 9000); + await loadRemotePlay(); + } catch (error) { showToast(error.message, 'error', 12000); } + }); + $('remote-play-worker-package-form').addEventListener('submit', async event => { + event.preventDefault(); + try { await ecosystemBusy(event.submitter, () => window.pork.remotePlayWorkerPackage({ label: $('remote-play-worker-label').value.trim(), serverOrigin: window.location.origin })); showToast('Worker package created. The enrollment token expires in 30 minutes.', 'ok', 10000); await loadRemotePlay(); } + catch (error) { showToast(error.message, 'error', 10000); } + }); + $('remote-play-workers').addEventListener('click', async event => { + const row = event.target.closest('[data-remote-worker-id]'); + if (!row || !event.target.closest('.remote-play-worker-remove') || !await showExactConfirmation('Revoke this worker, erase its HMAC key and cancel open jobs?', 'VERWIJDER')) return; + try { await ecosystemBusy(event.target.closest('button'), () => window.pork.remotePlayWorkerRemove(row.dataset.remoteWorkerId)); showToast('Remote Play worker revoked.', 'ok'); await loadRemotePlay(); } catch (error) { showToast(error.message, 'error'); } + }); + const runWorkerAction = async (button, action, phrase = null) => { + const worker = remotePlaySelectedWorker(_remotePlayPacket || {}); + if (!worker) return showToast('Select a Remote Play worker first.', 'error'); + if (phrase && !await showExactConfirmation(`Deze vaste actie wordt uitgevoerd op ${worker.label}.`, phrase)) return; + try { const job = await ecosystemBusy(button, () => window.pork.remotePlayWorkerAction({ workerId: worker.id, action, confirmation: phrase || undefined })); showToast(`${action} is queued securely for the worker (${job.id.slice(0, 8)}).`, 'ok', 9000); await loadRemotePlay(); } catch (error) { showToast(error.message, 'error', 12000); } + }; + $('remote-play-diagnose').addEventListener('click', event => runWorkerAction(event.currentTarget, 'diagnose')); + $('remote-play-open-client').addEventListener('click', event => runWorkerAction(event.currentTarget, 'open-client', 'OPEN CHIAKI')); + $('remote-play-install-client').addEventListener('click', event => runWorkerAction(event.currentTarget, 'install-client', 'INSTALL CHIAKI')); + $('remote-play-update-client').addEventListener('click', event => runWorkerAction(event.currentTarget, 'update-client', 'UPDATE CHIAKI')); + $('remote-play-stop').addEventListener('click', event => runWorkerAction(event.currentTarget, 'stop-session', 'STOP REMOTE PLAY')); + $('remote-play-profile-export').addEventListener('click', event => ecosystemBusy(event.currentTarget, () => window.pork.remotePlayProfileExport()).catch(error => showToast(error.message, 'error'))); + $('remote-play-upstream').addEventListener('click', () => window.pork.openShell('https://streetpea.github.io/chiaki-ng/setup/configuration/')); + $('websrv-files-root').addEventListener('click', event => ecosystemBusy(event.currentTarget, () => loadWebsrvFiles('/')).catch(error => showToast(error.message, 'error'))); + $('websrv-files-path').addEventListener('click', event => { const button = event.target.closest('.websrv-files-back'); if (button) loadWebsrvFiles(button.dataset.parent).catch(error => showToast(error.message, 'error')); }); + $('websrv-files-list').addEventListener('click', async event => { const row = event.target.closest('.websrv-file-row'); if (!row) return; try { if (event.target.closest('.websrv-file-open')) await loadWebsrvFiles(row.dataset.path); if (event.target.closest('.websrv-file-download')) await ecosystemBusy(event.target.closest('button'), () => window.pork.ecosystemWebsrvDownload(row.dataset.path)); } catch (error) { showToast(error.message, 'error', 10000); } }); + $('websrv-smb-source').addEventListener('change', () => { _websrvSmbPath = ''; if ($('websrv-smb-source').value) loadWebsrvSmb('').catch(error => showToast(error.message, 'error')); else { $('websrv-smb-list').innerHTML = '

    Choose a saved SMB source.

    '; $('websrv-smb-path').innerHTML = ''; } }); + $('websrv-smb-path').addEventListener('click', event => { const button = event.target.closest('.websrv-smb-back'); if (button) loadWebsrvSmb(button.dataset.parent).catch(error => showToast(error.message, 'error')); }); + $('websrv-smb-list').addEventListener('click', async event => { const row = event.target.closest('.websrv-file-row'); if (!row) return; try { if (event.target.closest('.websrv-file-open')) await loadWebsrvSmb(row.dataset.path); if (event.target.closest('.websrv-file-download')) await ecosystemBusy(event.target.closest('button'), () => window.pork.ecosystemWebsrvSmbDownload($('websrv-smb-source').value, row.dataset.path)); } catch (error) { showToast(error.message, 'error', 10000); } }); + $('ecosystem-homebrew-scan').addEventListener('click', event => ecosystemBusy(event.currentTarget, async () => renderHomebrew(await window.pork.ecosystemHomebrewScan())).catch(error => showToast(error.message, 'error'))); + $('ecosystem-websrv-discover').addEventListener('click', event => ecosystemBusy(event.currentTarget, async () => { + const result = await window.pork.ecosystemWebsrvDiscover(); + renderWebsrvDiscovery(result); + showToast(`${result.services?.length || 0} mDNS service(s) found.`, 'ok'); + }).catch(error => showToast(error.message, 'error'))); + $('ecosystem-homebrew-webui').addEventListener('click', async () => { try { const urls = await window.pork.ecosystemSourceWebUis(); window.pork.openShell(urls.websrv); } catch (error) { showToast(error.message, 'error'); } }); + $('ecosystem-homebrew').addEventListener('click', async event => { const row = event.target.closest('.ecosystem-homebrew-card'); if (!row || !event.target.closest('.ecosystem-homebrew-launch')) return; if (!await showExactConfirmation('websrv warns that launching can crash when other homebrew is already active.', 'START HOMEBREW')) return; try { await ecosystemBusy(event.target.closest('button'), () => window.pork.ecosystemHomebrewLaunch(row.dataset.id, 'START HOMEBREW')); showToast('Homebrew launch sent to websrv.', 'ok'); } catch (error) { showToast(error.message, 'error'); } }); + $('ecosystem-pacbrew-refresh').addEventListener('click', event => ecosystemBusy(event.currentTarget, async () => renderPacbrew(await window.pork.ecosystemPacbrewRefresh())).catch(error => showToast(error.message, 'error'))); + $('ecosystem-pacbrew-search').addEventListener('input', () => { if (_pacbrewPacket) renderPacbrew(_pacbrewPacket); }); + $('ecosystem-pacbrew-kind').addEventListener('change', () => { if (_pacbrewPacket) renderPacbrew(_pacbrewPacket); }); + $('ecosystem-pacbrew').addEventListener('click', async event => { + const upstream = event.target.closest('.ecosystem-open-upstream'); + if (upstream) { window.pork.openShell(upstream.dataset.url); return; } + const button = event.target.closest('.ecosystem-pacbrew-inspect'); + const row = event.target.closest('[data-pacbrew-name]'); + if (!button || !row) return; + try { + const item = await ecosystemBusy(button, () => window.pork.ecosystemPacbrewPackage(row.dataset.pacbrewName)); + $('ecosystem-pacbrew-detail').innerHTML = `
    ${escHtml(item.pkgname || item.name)} · ${escHtml(item.version || 'git')}${escHtml(item.description || 'No description in PKGBUILD.')}Runtime: ${(item.dependencies || []).map(escHtml).join(', ') || 'none'} · build: ${(item.buildDependencies || []).map(escHtml).join(', ') || 'none'} · license: ${(item.licenses || []).map(escHtml).join(', ') || 'unknown'}Recipe ${escHtml(String(item.sourceSha || '').slice(0, 12))} was parsed as text only; nothing was executed.
    `; + } catch (error) { showToast(error.message, 'error', 10000); } + }); + $('ecosystem-source-form').addEventListener('submit', async event => { event.preventDefault(); const button = event.submitter; try { const sources = await ecosystemBusy(button, () => window.pork.ecosystemSourceSave({ id: $('ecosystem-source-id').value || undefined, label: $('ecosystem-source-label').value, protocol: $('ecosystem-source-protocol').value, host: $('ecosystem-source-host').value, port: $('ecosystem-source-port').value || undefined, path: $('ecosystem-source-path').value, username: $('ecosystem-source-user').value, password: $('ecosystem-source-password').value || undefined })); renderSources(sources); event.target.reset(); $('ecosystem-source-path').value = '/'; $('ecosystem-source-id').value = ''; showToast('Remote source saved with encryption.', 'ok'); } catch (error) { showToast(error.message, 'error'); } }); + $('ecosystem-sources').addEventListener('click', async event => { + const row = event.target.closest('.ecosystem-source-row'); + if (!row) return; + const sources = await window.pork.ecosystemSources(); + const source = sources.find(item => item.id === row.dataset.id); + if (!source) return; + try { + if (event.target.closest('.ecosystem-source-test')) { + const result = await ecosystemBusy(event.target.closest('button'), () => window.pork.ecosystemSourceTest(row.dataset.id)); + showToast(result.ok ? `Source reachable in ${result.latencyMs} ms.` : `Source unreachable: ${result.error || result.status}`, result.ok ? 'ok' : 'error'); + } + if (event.target.closest('.ecosystem-source-edit')) { + $('ecosystem-source-id').value = source.id; + $('ecosystem-source-label').value = source.label; + $('ecosystem-source-protocol').value = source.protocol; + $('ecosystem-source-host').value = source.host; + $('ecosystem-source-port').value = source.port; + $('ecosystem-source-path').value = source.path; + $('ecosystem-source-user').value = source.username || ''; + $('ecosystem-source-password').value = ''; + } + if (event.target.closest('.ecosystem-source-delete') && await showExactConfirmation(`Delete ${source.label}?`, 'DELETE')) { + renderSources(await window.pork.ecosystemSourceRemove(source.id)); + } + } catch (error) { showToast(error.message, 'error'); } + }); + $('ezremote-refresh').addEventListener('click', event => ecosystemBusy(event.currentTarget, loadSources).catch(error => showToast(error.message, 'error'))); + $('ezremote-download-form').addEventListener('submit', async event => { + event.preventDefault(); + const destinationPath = $('ezremote-destination').value.trim(); + const required = `DOWNLOAD ${destinationPath}`; + const confirmation = await showExactConfirmation('ezRemote writes to the PS5 and cannot safely cancel an active download.', required); + if (!confirmation) return; + try { + const result = await ecosystemBusy(event.submitter, () => window.pork.ezRemoteDownload({ + sourceId: $('ezremote-source').value, + sourcePath: $('ezremote-source-path').value.trim(), + destinationPath, + size: Number($('ezremote-size').value || 0), + confirmation, + })); + showToast(result.deduplicated ? 'Reused the existing ezRemote task.' : 'ezRemote download safely queued in the durable transfer queue.', 'ok', 9000); + } catch (error) { showToast(error.message, 'error', 10000); } + }); + $('ecosystem-ezremote-webui').addEventListener('click', async () => { try { window.pork.openShell((await window.pork.ecosystemSourceWebUis()).ezremote); } catch (error) { showToast(error.message, 'error'); } }); + $('ecosystem-dpi-webui').addEventListener('click', async () => { try { window.pork.openShell((await window.pork.ecosystemSourceWebUis()).dpiV2); } catch (error) { showToast(error.message, 'error'); } }); + $('ecosystem-plugins-scan').addEventListener('click', event => ecosystemBusy(event.currentTarget, async () => renderPlugins(await window.pork.ecosystemPluginsScan())).catch(error => showToast(error.message, 'error'))); + $('ecosystem-plugin-install').addEventListener('click', async event => { + const targetRoot = $('ecosystem-plugin-target').value; + const confirmation = await showExactConfirmation(`Install one selected ELF or PRX into ${targetRoot}? An existing file is backed up and the upload is verified.`, 'INSTALL PLUGIN'); + if (!confirmation) return; + try { + const result = await ecosystemBusy(event.currentTarget, () => window.pork.ecosystemPluginInstall(targetRoot, confirmation)); + if (!result) return; + renderPlugins(result.inventory); + showToast(`${result.filename} installed and verified · ${String(result.sha256).slice(0, 12)}…`, 'ok', 10000); + } catch (error) { showToast(error.message, 'error', 10000); } + }); + $('ecosystem-plugins').addEventListener('click', async event => { + const upstream = event.target.closest('.ecosystem-open-upstream'); + if (upstream) { + window.pork.openShell(upstream.dataset.url); + return; + } + const row = event.target.closest('.ecosystem-plugin-row'); + if (!row) return; + try { + if (event.target.closest('.ecosystem-plugin-inspect')) { + const info = await ecosystemBusy(event.target.closest('button'), () => window.pork.ecosystemPluginInspect(row.dataset.id)); + let output = row.nextElementSibling?.classList.contains('ecosystem-plugin-inspection') ? row.nextElementSibling : null; + if (!output) { + output = document.createElement('pre'); + output.className = 'ecosystem-plugin-inspection'; + row.insertAdjacentElement('afterend', output); + } + output.textContent = `SHA-256: ${info.sha256}\nTitle IDs: ${info.titleIds.join(', ') || 'none reliably detected'}\nVersion candidates: ${info.versions.join(', ') || 'none detected'}`; + } + if (event.target.closest('.ecosystem-plugin-toggle')) { + const currentEnabled = event.target.closest('button').textContent.includes('Disable'); + if (!await showExactConfirmation(`${currentEnabled ? 'Disable' : 'Enable'} this plugin? Chimera creates a backup first and etaHEN must then be reloaded.`, 'WIJZIG PLUGIN')) return; + const result = await ecosystemBusy(event.target.closest('button'), () => window.pork.ecosystemPluginToggle(row.dataset.id, !currentEnabled, 'WIJZIG PLUGIN')); + renderPlugins(result.inventory); + } + } catch (error) { showToast(error.message, 'error'); } + }); + const scheduleCheatLoad = () => { _ecosystemCheatOffset = 0; clearTimeout(_ecosystemCheatTimer); _ecosystemCheatTimer = setTimeout(() => loadCheatsV2().catch(error => showToast(error.message, 'error')), 250); }; + $('ecosystem-cheats-search').addEventListener('input', scheduleCheatLoad); $('ecosystem-cheats-platform').addEventListener('change', scheduleCheatLoad); $('ecosystem-cheats-kind').addEventListener('change', scheduleCheatLoad); $('ecosystem-cheats-installed').addEventListener('change', scheduleCheatLoad); + $('ecosystem-cheats-sync').addEventListener('click', event => ecosystemBusy(event.currentTarget, async () => { await window.pork.ecosystemCheatsSync(); _ecosystemCheatOffset = 0; await loadCheatsV2(); showToast('Cheat and patch sources synchronized.', 'ok'); }).catch(error => showToast(error.message, 'error', 9000))); + $('ecosystem-cheats').addEventListener('click', async event => { const pager = event.target.closest('.ecosystem-cheat-page'); if (pager && !pager.disabled) { _ecosystemCheatOffset = Number(pager.dataset.offset) || 0; await loadCheatsV2(); return; } const source = event.target.closest('.ecosystem-open-upstream'); if (source) return window.pork.openShell(source.dataset.url); const row = event.target.closest('.ecosystem-cheat-row'); if (!row || !event.target.closest('.ecosystem-cheat-deploy')) return; const versionWarning = row.querySelector('.operations-status-warning')?.textContent.includes('Version mismatch'); const conflict = row.classList.contains('conflict'); if (!await showExactConfirmation(`Send this cheat or patch to the documented PS5 folder?${versionWarning ? '\n\nThe game version differs; you explicitly confirm that you want to continue.' : ''}${conflict ? '\n\nOther formats for the same version will be replaced safely.' : ''}`, 'INSTALLEER CHEAT')) return; try { await ecosystemBusy(event.target.closest('button'), () => window.pork.ecosystemCheatDeploy({ key: row.dataset.key, forceVersion: versionWarning, replaceConflicts: conflict, confirmation: 'INSTALLEER CHEAT' })); showToast('Cheat or patch installed with backup and checksum verification.', 'ok'); } catch (error) { showToast(error.message, 'error', 9000); } }); + $('ecosystem-saves-refresh').addEventListener('click', event => ecosystemBusy(event.currentTarget, async () => renderRemoteSaves(await window.pork.ecosystemRemoteSaves())).catch(error => showToast(error.message, 'error'))); + $('ecosystem-saves-all').addEventListener('click', async event => { if (!await showConfirm('Export every discovered title to Save Vault one by one? This can take a long time and use substantial storage.')) return; try { const result = await ecosystemBusy(event.currentTarget, () => window.pork.ecosystemSaveSnapshotAll()); showToast(`${result.succeeded}/${result.total} save backups created.`, result.failed ? 'info' : 'ok', 9000); await loadSaveVault(); } catch (error) { showToast(error.message, 'error'); } }); + $('ecosystem-remote-saves').addEventListener('click', async event => { const row = event.target.closest('.ecosystem-save-row'); if (!row || !event.target.closest('.ecosystem-save-snapshot')) return; try { await ecosystemBusy(event.target.closest('button'), () => window.pork.ecosystemSaveSnapshot(row.dataset.titleId)); showToast('Save Vault-snapshot gemaakt en geverifieerd.', 'ok'); await loadSaveVault(); } catch (error) { showToast(error.message, 'error'); } }); + $('ecosystem-save-vault').addEventListener('click', async event => { + const row = event.target.closest('.ecosystem-vault-row'); + if (!row) return; + const button = event.target.closest('button'); + try { + if (button?.classList.contains('ecosystem-save-verify')) { + const result = await ecosystemBusy(button, () => window.pork.ecosystemSaveVerify(row.dataset.id)); + showToast(result.ok ? `${result.titleId}: SHA-256, ZIP CRC and ${result.archive.entries.length} save file(s) are valid.` : `Snapshot rejected: ${result.error}`, result.ok ? 'ok' : 'error', 10000); + } + if (button?.classList.contains('ecosystem-save-restore')) { + if (!_ecosystemSaveCapabilities?.automatedRestore) throw new Error('Restore capability is not active.'); + let plan = await ecosystemBusy(button, () => window.pork.ecosystemSaveRestorePlan(row.dataset.id)); + if (plan.requiresTargetUser) { + const choices = plan.users.map(user => `${user.uid} (${user.name || 'name unknown'})`).join('\n'); + const targetUid = await showPrompt(`Choose the exact target UID for ${plan.titleId}:\n\n${choices}`, plan.users[0]?.uid || ''); + if (!targetUid) return; + plan = await ecosystemBusy(button, () => window.pork.ecosystemSaveRestorePlan(row.dataset.id, targetUid.trim())); + } + const incoming = plan.impact.incomingSaves.map(save => `• ${save.name} — ${ecosystemBytes(save.bytes)} — ${String(save.sha256).slice(0, 12)}…`).join('\n'); + const approved = await showConfirm( + `DRY RUN — nothing has changed on the PS5 yet.\n\n` + + `Title: ${plan.titleId}\nTarget user: ${plan.targetUid}\nExisting saves: ${plan.impact.existingSaves.length}\nIncoming saves: ${plan.impact.incomingSaves.length}\n\n${incoming}\n\n` + + 'If you continue, Chimera first creates a new verified snapshot. Account mismatch, zeroed param.sfo or changed source or destination saves stop the process. A byte-for-byte export check follows. Automatic rollback is intentionally not performed. Continue to the exact confirmation?' + ); + if (!approved) return; + const phrase = await showExactConfirmation('The dry run was approved. Now confirm the actual restore.', plan.confirmationPhrase); + if (!phrase) return; + const result = await ecosystemBusy(button, () => window.pork.ecosystemSaveRestore(plan.token, phrase)); + showToast(`${result.titleId} restored safely: ${result.verifiedEntries} file(s) verified byte-for-byte. Pre- and post-snapshots were retained.`, 'ok', 15000); + await loadSaveVault(); + } + if (button?.classList.contains('ecosystem-save-download')) await ecosystemBusy(button, () => window.pork.ecosystemSaveDownload(row.dataset.id)); + if (button?.classList.contains('ecosystem-save-delete') && await showExactConfirmation('Permanently delete this local save backup?', 'DELETE')) { await window.pork.ecosystemSaveDelete(row.dataset.id); await loadSaveVault(); } + } catch (error) { showToast(error.message, 'error', 15000); } + }); + $('itemzflow-refresh').addEventListener('click', event => ecosystemBusy(event.currentTarget, loadItemzflow).catch(error => showToast(error.message, 'error'))); + $('itemzflow-homebrew-open').addEventListener('click', () => navigateEcosystemTab('homebrew')); + $('itemzflow-log-select').addEventListener('change', renderItemzflowEvents); + $('itemzflow-level').addEventListener('change', renderItemzflowEvents); + $('itemzflow-jobs-only').addEventListener('change', renderItemzflowEvents); + $('platform-lab-refresh').addEventListener('click', event => ecosystemBusy(event.currentTarget, loadPlatformLab).catch(error => showToast(error.message, 'error'))); + $('platform-bindings-refresh').addEventListener('click', event => ecosystemBusy(event.currentTarget, async () => renderPlatformBindings(await window.pork.platformBindingsRefresh())).catch(error => showToast(error.message, 'error'))); + $('platform-bindings').addEventListener('click', event => { + const button = event.target.closest('.platform-binding-review'); + if (!button) return; + ecosystemBusy(button, async () => { await window.pork.platformBindingReview({ id: button.dataset.id, tag: button.dataset.tag }); renderPlatformBindings(await window.pork.platformBindings()); }).then(() => showToast(platformLabText('reviewed', 'Upstream evidence reviewed.'), 'ok')).catch(error => showToast(error.message, 'error')); + }); + $('platform-autoloader-preview').addEventListener('click', event => ecosystemBusy(event.currentTarget, async () => renderAutoloaderPreview(await window.pork.unifiedAutoloaderPreview({ id: $('platform-autoloader-profile').value }))).catch(error => showToast(error.message, 'error'))); + $('platform-autoloader-profile').addEventListener('change', () => { + const profile = (_platformLabState.profiles || []).find(item => item.id === $('platform-autoloader-profile').value); + if (profile) $('platform-profile-json').value = JSON.stringify(profile, null, 2); + }); + $('platform-profile-save').addEventListener('click', event => ecosystemBusy(event.currentTarget, async () => { + const profile = JSON.parse($('platform-profile-json').value); + await window.pork.unifiedAutoloaderSave(profile); + renderAutoloader(await window.pork.unifiedAutoloaderList()); + }).then(() => showToast(platformLabText('profileSaved', 'Profile saved.'), 'ok')).catch(error => showToast(error.message, 'error'))); + $('platform-profile-delete').addEventListener('click', event => ecosystemBusy(event.currentTarget, async () => { + await window.pork.unifiedAutoloaderRemove({ id: $('platform-autoloader-profile').value }); + renderAutoloader(await window.pork.unifiedAutoloaderList()); + }).then(() => showToast(platformLabText('profileDeleted', 'Profile deleted.'), 'ok')).catch(error => showToast(error.message, 'error'))); + $('platform-profile-recovery').addEventListener('click', event => ecosystemBusy(event.currentTarget, async () => { + const result = await window.pork.unifiedAutoloaderRecovery({ id: $('platform-autoloader-profile').value }); + $('platform-profile-output').innerHTML = `
    ${escHtml(JSON.stringify(result, null, 2))}
    `; + }).catch(error => showToast(error.message, 'error'))); + $('platform-websrv-refresh').addEventListener('click', event => ecosystemBusy(event.currentTarget, async () => renderPlatformWebsrv(await window.pork.platformWebsrvDiscover())).catch(error => showToast(error.message, 'error'))); + $('platform-remote-refresh').addEventListener('click', event => ecosystemBusy(event.currentTarget, async () => renderRemoteIntelligence(await window.pork.remotePlayIntelligence())).catch(error => showToast(error.message, 'error'))); + $('platform-shadps4-scan').addEventListener('click', event => ecosystemBusy(event.currentTarget, async () => { const rootId = $('platform-shadps4-root').value; if (rootId) await window.pork.shadps4Select({ rootId }); renderShadps4Scan(await window.pork.shadps4Scan()); }).catch(error => showToast(error.message, 'error'))); + $('platform-artifact-inspect').addEventListener('click', event => ecosystemBusy(event.currentTarget, async () => { + await window.pork.platformArtifactInspect({ rootId: $('platform-artifact-root').value, relativePath: $('platform-artifact-relative').value }); + renderArtifacts(await window.pork.platformArtifacts()); + }).then(() => showToast(platformLabText('artifactHashed', 'Artifact hashed.'), 'ok')).catch(error => showToast(error.message, 'error'))); + $('platform-artifacts').addEventListener('click', event => { + const button = event.target.closest('.platform-artifact-review'); + if (!button) return; + ecosystemBusy(button, async () => { await window.pork.platformArtifactReview({ artifactId: button.dataset.id, sha256: button.dataset.sha }); renderArtifacts(await window.pork.platformArtifacts()); }).then(() => showToast(platformLabText('hashReviewed', 'Exact hash reviewed.'), 'ok')).catch(error => showToast(error.message, 'error')); + }); + $('platform-autoloader-stage').addEventListener('click', event => ecosystemBusy(event.currentTarget, async () => { + const artifactIds = [...document.querySelectorAll('.platform-artifact-select:checked')].map(input => input.value); + const result = await window.pork.platformAutoloaderStage({ profileId: $('platform-autoloader-profile').value, targetRootId: $('platform-stage-root').value, artifactIds }); + $('platform-stage-output').innerHTML = `
    ${escHtml(result.lifecycleState)} · ${escHtml(result.destinationLabel)}
    `; + renderArtifacts(await window.pork.platformArtifacts()); + }).then(() => showToast(platformLabText('staged', 'Profile staged atomically.'), 'ok')).catch(error => showToast(error.message, 'error'))); + $('platform-search-form').addEventListener('submit', event => { event.preventDefault(); ecosystemBusy(event.submitter, async () => renderSearch(await window.pork.platformSearch({ query: $('platform-search-query').value }))).catch(error => showToast(error.message, 'error')); }); + $('platform-shadps4-configure').addEventListener('click', event => ecosystemBusy(event.currentTarget, async () => { + await window.pork.shadps4LaunchConfigure({ rootId: $('platform-shadps4-exe-root').value, relativePath: $('platform-shadps4-exe-relative').value }); + renderLaunchProbe(await window.pork.shadps4LaunchProbe()); + }).then(() => showToast(platformLabText('launcherConfigured', 'Launcher configured.'), 'ok')).catch(error => showToast(error.message, 'error'))); + $('platform-shadps4').addEventListener('click', event => { + const button = event.target.closest('.platform-shadps4-plan'); + if (!button) return; + ecosystemBusy(button, async () => { + _platformLabState.launchPlan = await window.pork.shadps4LaunchPlan({ itemId: button.dataset.id }); + $('platform-shadps4-launch').innerHTML = `
    ${escHtml(_platformLabState.launchPlan.itemLabel)}
    ${escHtml(platformLabText('exactConfirmation', 'Enter the exact confirmation shown below.'))}
    `; + }).catch(error => showToast(error.message, 'error')); + }); + $('platform-shadps4-launch').addEventListener('click', event => { + const button = event.target.closest('#platform-shadps4-launch-confirm'); + if (!button || !_platformLabState.launchPlan) return; + ecosystemBusy(button, async () => { + const result = await window.pork.shadps4Launch({ token: _platformLabState.launchPlan.token, confirmation: $('platform-shadps4-confirmation').value }); + _platformLabState.launchPlan = null; + $('platform-shadps4-launch').innerHTML = `
    ${escHtml(platformLabText('started', 'Started'))} · PID ${result.pid}
    `; + }).catch(error => showToast(error.message, 'error')); + }); + $('ecosystem-linux-refresh').addEventListener('click', event => ecosystemBusy(event.currentTarget, loadLinuxLab).catch(error => showToast(error.message, 'error'))); + $('ecosystem-linux-components').addEventListener('click', event => { const button = event.target.closest('.ecosystem-open-upstream'); if (button) window.pork.openShell(button.dataset.url); }); + $('ecosystem-timeline-start').addEventListener('click', event => ecosystemBusy(event.currentTarget, async () => renderTimeline(await window.pork.ecosystemTimelineStart(Number($('ecosystem-timeline-port').value)))).catch(error => showToast(error.message, 'error'))); + $('ecosystem-timeline-stop').addEventListener('click', event => ecosystemBusy(event.currentTarget, async () => renderTimeline(await window.pork.ecosystemTimelineStop())).catch(error => showToast(error.message, 'error'))); + $('ecosystem-timeline-import').addEventListener('click', event => ecosystemBusy(event.currentTarget, async () => renderTimeline(await window.pork.ecosystemTimelineImport())).catch(error => showToast(error.message, 'error'))); + $('ecosystem-timeline-options').addEventListener('submit', event => { event.preventDefault(); ecosystemBusy(event.submitter, async () => renderTimeline(await window.pork.ecosystemTimelineConfigure({ enabled: $('ecosystem-timeline-persist').checked, retentionDays: Number($('ecosystem-timeline-retention').value), maxEvents: Number($('ecosystem-timeline-max').value) }))).then(() => showToast('Timeline policy saved; exports remain redacted by default.', 'ok')).catch(error => showToast(error.message, 'error')); }); + $('ecosystem-timeline-export').addEventListener('click', event => ecosystemBusy(event.currentTarget, () => window.pork.ecosystemTimelineExport({ search: $('ecosystem-timeline-search').value, level: $('ecosystem-timeline-level').value })).catch(error => showToast(error.message, 'error'))); + const scheduleTimelineFilter = () => { clearTimeout(_timelineFilterTimer); _timelineFilterTimer = setTimeout(() => loadTimeline().catch(error => showToast(error.message, 'error')), 250); }; + $('ecosystem-timeline-search').addEventListener('input', scheduleTimelineFilter); + $('ecosystem-timeline-level').addEventListener('change', scheduleTimelineFilter); + window.pork?.on?.('ecosystem:timeline', () => { if (_ecosystemActiveTab === 'timeline' && document.querySelector('#page-ecosystem.active')) loadTimeline().catch(() => {}); }); + window.pork?.on?.('ecosystem:health', () => { if (_ecosystemActiveTab === 'overview' && document.querySelector('#page-ecosystem.active')) loadEcosystemOverview().catch(() => {}); }); + window.pork?.on?.('platform:operation', () => { if (_ecosystemActiveTab === 'platform-lab') window.pork.platformOperations().then(renderOperations).catch(() => {}); }); + window.pork?.on?.('game-updates:progress', () => { + if (_ecosystemActiveTab !== 'game-updates' || !document.querySelector('#page-ecosystem.active')) return; + clearTimeout(_gameUpdateRefreshTimer); _gameUpdateRefreshTimer = setTimeout(() => loadGameUpdates().catch(() => {}), 500); + }); +} + +async function loadEcosystem({ background = false } = {}) { + buildEcosystemPage(); + if (background) return loadEcosystemOverview(); + const requestedTab = window.chimeraRequestedEcosystemTab; + if (requestedTab) { + _ecosystemActiveTab = requestedTab; + window.chimeraRequestedEcosystemTab = null; + document.querySelectorAll('.ecosystem-tab').forEach(button => { + const active = button.dataset.ecosystemTab === requestedTab; + button.classList.toggle('active', active); + button.setAttribute('aria-selected', String(active)); + button.tabIndex = active ? 0 : -1; + }); + document.querySelectorAll('.ecosystem-panel').forEach(panel => { + const active = panel.dataset.ecosystemPanel === requestedTab; + panel.classList.toggle('active', active); + panel.hidden = !active; + }); + } + const tabLoad = loadEcosystemTab(_ecosystemActiveTab); + if (_ecosystemActiveTab !== 'overview' && !_ecosystemLoaded) { + const headerLoad = loadEcosystemOverview().catch(error => { + $('ecosystem-health').textContent = localizedEcosystemError(error.message || 'Status unavailable'); + $('ecosystem-health').className = 'operations-health warning'; + }); + await Promise.all([tabLoad, headerLoad]); + return; + } + await tabLoad; +} +// end diff --git a/renderer/modules/elf-arsenal.js b/renderer/modules/elf-arsenal.js new file mode 100644 index 0000000..2748f1d --- /dev/null +++ b/renderer/modules/elf-arsenal.js @@ -0,0 +1,213 @@ +// ELF Arsenal native Chimera client. The upstream WebUI remains available in a +// separate browser tab, but is no longer embedded: its opaque iframe errors +// cannot be diagnosed and its storage assumptions conflict with sandboxing. + +let _eaPath = '/'; +let _eaConnected = false; + +function eaFriendlyError(value) { + const message = String(value || '').trim(); + if (/Configureer eerst een geldig PS5-adres/i.test(message)) return 'Configure a PS5 host in Settings first.'; + if (/ELF Arsenal-payload.*gestart/i.test(message)) return 'Start the ELF Arsenal payload explicitly, then retry.'; + return message || 'ELF Arsenal is unavailable on port 6969.'; +} + +function eaBytes(value) { + const bytes = Number(value || 0); + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`; + return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`; +} + +function eaJoin(base, name) { + const cleanName = String(name || '').replace(/^\/+|\/+$/g, ''); + return base === '/' ? `/${cleanName}` : `${String(base).replace(/\/+$/, '')}/${cleanName}`; +} + +function eaParent(value) { + const parts = String(value || '/').split('/').filter(Boolean); + parts.pop(); + return parts.length ? `/${parts.join('/')}` : '/'; +} + +function eaSetStatus(message, type = '') { + const status = document.getElementById('ea-embed-status'); + if (!status) return; + status.textContent = message; + status.className = `ea-native-status ${type}`; +} + +function eaRenderList(packet) { + const target = document.getElementById('ea-native-list'); + const pathLabel = document.getElementById('ea-native-path'); + if (!target || !pathLabel) return; + pathLabel.innerHTML = `${escHtml(_eaPath)}`; + const entries = (packet.entries || []).slice().sort((left, right) => + Number(Boolean(right.is_dir)) - Number(Boolean(left.is_dir)) || String(left.name).localeCompare(String(right.name))); + target.innerHTML = entries.length ? entries.map(entry => { + const remotePath = eaJoin(_eaPath, entry.name); + const isElf = !entry.is_dir && /\.elf$/i.test(entry.name); + return `
    + +
    ${escHtml(entry.name)}${entry.is_dir ? 'Directory' : eaBytes(entry.size)}${entry.mode ? ` · ${escHtml(entry.mode)}` : ''}
    +
    + ${entry.is_dir ? '' : `${isElf ? '' : ''}`} +
    +
    `; + }).join('') : '
    This directory is emptyChoose another safe root or upload a file explicitly.
    '; + const lock = document.getElementById('ea-native-lock'); + if (lock) { + lock.textContent = packet.safety_lock ? 'Safety lock enabled' : 'Safety lock not reported'; + lock.className = `operations-status ${packet.safety_lock ? 'operations-status-compatible' : 'operations-status-unknown'}`; + } +} + +async function eaLoadDirectory(path = _eaPath) { + _eaPath = String(path || '/'); + const target = document.getElementById('ea-native-list'); + if (target) target.innerHTML = '

    Loading directory…

    '; + try { + const [packet, job] = await Promise.all([ + window.pork.eaList(_eaPath), + window.pork.eaJobStatus().catch(() => ({ busy: false })), + ]); + eaRenderList(packet); + const jobTarget = document.getElementById('ea-native-job'); + if (jobTarget) jobTarget.textContent = job.busy + ? `File operation active${job.progress != null ? ` · ${job.progress}%` : ''}` + : 'No active file operation'; + } catch (error) { + if (target) target.innerHTML = `
    Directory unavailable${escHtml(error.message)}
    `; + eaSetStatus(`File API error: ${error.message}`, 'error'); + } +} + +async function eaReload() { + eaSetStatus('Checking ELF Arsenal…'); + try { + const result = await window.pork.eaEmbedUrl(); + const open = document.getElementById('ea-embed-open'); + _eaConnected = result.connected === true; + if (!_eaConnected) { + if (open) { open.disabled = true; delete open.dataset.url; } + eaSetStatus(eaFriendlyError(result.error), 'error'); + const list = document.getElementById('ea-native-list'); + if (list) list.innerHTML = '
    ELF Arsenal is offlineStart it explicitly through Payload Manager, then retry.
    '; + return; + } + if (open) { + open.disabled = false; + open.dataset.url = result.directUrl || result.url; + } + eaSetStatus(result.version ? `Online · ${result.version}` : 'Online', 'ok'); + document.getElementById('ea-native-version').textContent = result.version || 'Unknown'; + await eaLoadDirectory(_eaPath); + } catch (error) { + _eaConnected = false; + eaSetStatus(`Connection error: ${eaFriendlyError(error.message)}`, 'error'); + } +} + +function buildElfArsenalEmbed() { + const root = document.getElementById('page-elf-arsenal'); + if (!root) return; + root.innerHTML = ` + +
    + 1Start servicePayload Manager, explicit + 2BrowseChoose a reviewed ELF + 3InspectPath and operation + 4LaunchExact confirmation +
    +
    +
    —Version
    +
    No active jobFile operation
    +
    UnknownUpstream safety lock
    +
    +
    +
    +

    PS5 files

    Browse with Chimera's authenticated API. The original upstream WebUI opens separately and can no longer crash this page.

    +
    + + + + +
    +
    +
    +

    Waiting for ELF Arsenal…

    +
    +

    FTP alone is not enough: ELF Arsenal must be running on port 6969. Launch and upload actions always require an explicit confirmation.

    `; + + document.getElementById('ea-embed-open').addEventListener('click', () => { + const url = document.getElementById('ea-embed-open')?.dataset.url; + if (url) window.pork.openShell(url); + }); + document.getElementById('ea-embed-start').addEventListener('click', async (event) => { + const button = event.currentTarget; + button.disabled = true; + eaSetStatus('Starting ELF Arsenal explicitly through Payload Manager…', 'warning'); + try { + await window.pork.eaStart(); + showToast('ELF Arsenal started. Rechecking the service.', 'ok'); + await eaReload(); + } catch (error) { + eaSetStatus(`Start failed: ${error.message}`, 'error'); + showToast(`ELF Arsenal start failed: ${error.message}`, 'error'); + } finally { + button.disabled = false; + } + }); + document.getElementById('ea-embed-reload').addEventListener('click', eaReload); + document.getElementById('ea-native-path').addEventListener('click', event => { + const button = event.target.closest('.ea-native-parent'); + if (button) eaLoadDirectory(button.dataset.path); + }); + root.querySelectorAll('.ea-native-root').forEach(button => button.addEventListener('click', () => eaLoadDirectory(button.dataset.path))); + document.getElementById('ea-native-list').addEventListener('click', async event => { + const row = event.target.closest('.ea-native-row'); + if (!row) return; + const button = event.target.closest('button'); + try { + if (button?.classList.contains('ea-native-open')) await eaLoadDirectory(row.dataset.path); + if (button?.classList.contains('ea-native-download')) await window.pork.eaDownload(row.dataset.path, row.dataset.name); + if (button?.classList.contains('ea-native-launch')) { + const confirmation = await showExactConfirmation(`Launch ${row.dataset.path} through ELF Arsenal?`, 'LAUNCH ELF'); + if (!confirmation) return; + await window.pork.eaLaunch(row.dataset.path, [], false); + showToast(`${row.dataset.name} launch request sent.`, 'ok'); + } + } catch (error) { showToast(error.message, 'error', 10000); } + }); + document.getElementById('ea-native-upload').addEventListener('click', async () => { + if (!_eaConnected) return showToast('Start ELF Arsenal first.', 'error'); + const confirmation = await showExactConfirmation(`Upload one selected file to ${_eaPath}?`, 'UPLOAD FILE'); + if (!confirmation) return; + try { + const uploadRoot = _eaPath === '/' ? '/' : `${_eaPath.replace(/\/+$/, '')}/`; + const result = await window.pork.eaUpload(null, uploadRoot); + if (result) { + showToast(`Uploaded ${eaBytes(result.bytes)} to ${result.remotePath}.`, 'ok'); + await eaLoadDirectory(_eaPath); + } + } catch (error) { showToast(error.message, 'error', 10000); } + }); + eaReload(); +} + +async function loadElfArsenal() { + if (!document.getElementById('ea-native-list')) buildElfArsenalEmbed(); + else await eaReload(); +} diff --git a/renderer/modules/etahen-link-ux.js b/renderer/modules/etahen-link-ux.js new file mode 100644 index 0000000..2ba9ae7 --- /dev/null +++ b/renderer/modules/etahen-link-ux.js @@ -0,0 +1,63 @@ +'use strict'; + +(function exposeEtahenLinkUx(root, factory) { + const api = factory(); + if (typeof module === 'object' && module.exports) module.exports = api; + if (root) root.ChimeraLinkUx = api; +})(typeof window === 'object' ? window : null, () => { + const STATES = new Set(['online', 'offline', 'unknown']); + const JOB_STATES = new Set(['none', 'staged', 'approval_required', 'recovery_required']); + const safeState = (value, allowed, fallback) => allowed.has(String(value || '')) ? String(value) : fallback; + const text = (value, fallback = '') => String(value || fallback).slice(0, 160); + + function createModel(packet = {}) { + const runtime = packet.runtime || {}; + const last = packet.lastInventory || null; + const etaHenState = safeState(runtime.state, STATES, 'unknown'); + const agentState = safeState(packet.agent?.state, STATES, etaHenState === 'online' && last ? 'online' : 'unknown'); + const chimeraState = last ? 'online' : 'unknown'; + const jobState = safeState(packet.job?.state, JOB_STATES, 'none'); + const stagedCount = Math.max(0, Number(packet.job?.stagedCount) || 0); + return Object.freeze({ + etaHen: Object.freeze({ state: etaHenState, version: text(runtime.identity?.version, '—') }), + agent: Object.freeze({ state: agentState, paired: packet.agent?.paired === true }), + chimera: Object.freeze({ state: chimeraState, lastScanAt: last?.completedAt || null }), + transfer: Object.freeze({ state: jobState, stagedCount, installedCount: 0 }), + recoveryRequired: jobState === 'recovery_required', + approvalRequired: jobState === 'approval_required', + }); + } + + function exactConfirmation({ action, target, runtimeEpoch, localIntent, recoveryAvailable }) { + if (!/^[A-Z][A-Z0-9_]{2,31}$/.test(String(action || ''))) throw new Error('Invalid action.'); + if (!/^[A-Za-z0-9_-]{8,96}$/.test(String(target || '')) || !/^[A-Za-z0-9_-]{8,96}$/.test(String(runtimeEpoch || ''))) throw new Error('Invalid confirmation identity.'); + if (localIntent !== true) throw new Error('Local console intent is required.'); + if (recoveryAvailable !== true) throw new Error('Verified recovery is required.'); + return `CONFIRM ${action} ${target} ${runtimeEpoch}`; + } + + function mount({ root, loadStatus, translate = (_key, fallback) => fallback } = {}) { + if (!root || typeof loadStatus !== 'function') return null; + const t = translate; + root.innerHTML = ``; + const health = root.querySelector('.etahen-link-health'); const alert = root.querySelector('.etahen-link-alert'); const button = root.querySelector('#etahen-link-refresh'); + const label = state => t(`etahenLink.state.${state}`, state === 'online' ? 'Online' : state === 'offline' ? 'Offline' : 'Unknown'); + const render = packet => { + const model = createModel(packet); + health.innerHTML = [['etaHEN', model.etaHen], [t('etahenLink.agent', 'Link agent'), model.agent], ['Chimera', model.chimera]].map(([name, item]) => ``).join(''); + alert.className = `etahen-link-alert ${model.recoveryRequired ? 'is-critical' : ''}`; + alert.textContent = model.recoveryRequired ? t('etahenLink.recoveryRequired', 'Recovery required before any new action.') : model.approvalRequired ? t('etahenLink.approvalRequired', 'Approval must be confirmed locally on the console.') : model.transfer.stagedCount ? t('etahenLink.stagedOnly', 'Artifact staged — not installed.') : t('etahenLink.noPendingWork', 'No pending Link work.'); + return model; + }; + const refresh = async () => { button.disabled = true; try { return render(await loadStatus()); } catch (_) { return render({ runtime: { state: 'unknown' } }); } finally { button.disabled = false; } }; + button.addEventListener('click', refresh); root.addEventListener('keydown', event => { if (event.key === 'Enter' && event.target === root) refresh(); }); + root.tabIndex = 0; refresh(); + return Object.freeze({ refresh, render }); + } + + return Object.freeze({ createModel, exactConfirmation, mount }); +}); diff --git a/renderer/modules/ftpbrowser.js b/renderer/modules/ftpbrowser.js new file mode 100644 index 0000000..777693c --- /dev/null +++ b/renderer/modules/ftpbrowser.js @@ -0,0 +1,115 @@ +// ── FTP Browser ─────────────────────────────────────────────────────────────── + +let ftpBrowserResolve = null; +let ftpBrowserCwd = '/'; + +function openFtpBrowser(startPath = '/') { + return new Promise(resolve => { + ftpBrowserResolve = resolve; + openAccessibleDialog($('ftp-browser-overlay'), $('ftp-browser-close')); + ftpBrowserNavigate(startPath); + }); +} + +function closeFtpBrowser(result) { + closeAccessibleDialog($('ftp-browser-overlay')); + if (ftpBrowserResolve) { ftpBrowserResolve(result); ftpBrowserResolve = null; } +} + +async function ftpBrowserNavigate(path) { + ftpBrowserCwd = path; + $('ftp-browser-cwd').textContent = path; + $('ftp-browser-up').disabled = (path === '/'); + const body = $('ftp-browser-body'); + body.innerHTML = '
    Loading…
    '; + try { + const entries = await window.pork.ftpListDir(path); + if (!entries.length) { + body.innerHTML = '
    Empty folder.
    '; + return; + } + body.innerHTML = entries.map(e => ` +
    + ${e.isDir ? '📁' : '📄'} + ${escHtml(e.name)} +
    + `).join(''); + } catch (e) { + body.innerHTML = `
    Error: ${escHtml(e.message)}
    `; + } +} + +$('ftp-browser-body').addEventListener('click', e => { + const entry = e.target.closest('.ftp-entry-dir'); + if (!entry) return; + const name = entry.dataset.name; + const next = (ftpBrowserCwd.endsWith('/') ? ftpBrowserCwd : ftpBrowserCwd + '/') + name; + ftpBrowserNavigate(next); +}); + +$('ftp-browser-up').addEventListener('click', () => { + const parts = ftpBrowserCwd.replace(/\/$/, '').split('/').filter(Boolean); + parts.pop(); + ftpBrowserNavigate('/' + parts.join('/') || '/'); +}); + +$('ftp-browser-select').addEventListener('click', () => closeFtpBrowser(ftpBrowserCwd)); +$('ftp-browser-cancel').addEventListener('click', () => closeFtpBrowser(null)); +$('ftp-browser-close').addEventListener('click', () => closeFtpBrowser(null)); +$('ftp-browser-overlay').addEventListener('keydown', event => { + if (event.key === 'Escape') closeFtpBrowser(null); +}); + +$('btn-browse-ftp').addEventListener('click', async () => { + if (!state.connected) { setStatus('Connect to FTP first to browse remote paths', 'error'); return; } + const selected = await openFtpBrowser(ftpBrowserCwd || '/'); + if (!selected) return; + // Auto-add immediately — no separate "+ Add" click required + if (remoteGamePaths.some(p => p.path === selected)) { + setStatus('That path is already in the list', 'ok'); + return; + } + const label = $('new-remote-label').value.trim() + || selected.replace(/\/$/, '').split('/').filter(Boolean).pop() + || selected; + remoteGamePaths = [...remoteGamePaths, { label, path: selected }]; + await saveRemoteGamePaths(); + renderRemoteGamePaths(); + $('new-remote-label').value = ''; + $('new-remote-path').value = ''; +}); + +// Add payload source +$('btn-add-payload-source').addEventListener('click', async () => { + const name = $('new-source-name').value.trim(); + const url = $('new-source-url').value.trim(); + if (!name || !url) { setStatus('Enter both a name and a source URL', 'error'); return; } + try { + await window.pork.payloadSourcesAdd(name, url); + $('new-source-name').value = ''; + $('new-source-url').value = ''; + await renderSettingsPayloadSources(); + setStatus(`Source "${name}" added`, 'ok'); + } catch (e) { + setStatus(`Failed to add source: ${e.message}`, 'error'); + } +}); + +// Delegated clicks in settings payload source list +$('settings-payload-sources').addEventListener('click', async e => { + const row = e.target.closest('[data-source-id]'); + if (!row) return; + const id = Number(row.dataset.sourceId); + + if (e.target.closest('.btn-source-remove')) { + const confirmation = await showExactConfirmation('Remove this payload source? Downloaded files will not be deleted.', 'VERWIJDER'); + if (!confirmation) return; + await window.pork.payloadSourcesRemove(id, confirmation); + await renderSettingsPayloadSources(); + } else if (e.target.closest('.source-toggle')) { + await window.pork.payloadSourcesToggle(id); + await renderSettingsPayloadSources(); + } +}); + diff --git a/renderer/modules/game-source-scan.js b/renderer/modules/game-source-scan.js new file mode 100644 index 0000000..3f30426 --- /dev/null +++ b/renderer/modules/game-source-scan.js @@ -0,0 +1,23 @@ +'use strict'; + +$('btn-rescan-game-sources').addEventListener('click', async () => { + const btn = $('btn-rescan-game-sources'); + btn.disabled = true; + const orig = btn.textContent; + btn.textContent = 'Scanning…'; + setStatus('Scanning local game source folders…'); + try { + const { total, added } = await window.pork.scanBackups(); + await loadGames(); + await loadDashboard(); + const msg = added > 0 + ? `Found ${total} game(s) — ${added} new, fetching metadata…` + : `Scan complete: ${total} game(s) found, none new`; + setStatus(msg, 'ok'); + } catch (e) { + setStatus(e.message, 'error'); + } finally { + btn.disabled = false; + btn.textContent = orig; + } +}); diff --git a/renderer/modules/games.js b/renderer/modules/games.js new file mode 100644 index 0000000..f57f818 --- /dev/null +++ b/renderer/modules/games.js @@ -0,0 +1,481 @@ +// ── Games page ──────────────────────────────────────────────────────────────── +async function loadGames() { + try { + const library = await window.pork.listLibraryTitles({ limit: 1000 }); + state.games = window.chimeraLibraryModel.items(library); + window.pork.shadowMountStatus?.().then(result => { + const byGame = new Map(); + for (const entry of (result.model?.entries || [])) { + if (!entry.titleId) continue; + const current = byGame.get(entry.titleId) || []; + current.push(entry); + byGame.set(entry.titleId, current); + } + state.games = state.games.map(game => { + const matches = byGame.get(game.game_id) || []; + if (!matches.length) return game; + return { ...game, shadow_mount: { + state: matches.some(entry => entry.state === 'installed') ? 'installed' : matches[0].state, + sources: matches.length, + experimental: matches.some(entry => entry.format?.support === 'experimental'), + formats: [...new Set(matches.map(entry => entry.format?.id).filter(Boolean))], + } }; + }); + renderGames(); + }).catch(() => {}); + // Refresh hash summaries (fire-and-forget so badges update when ready) + window.pork.hashGameSummary().then(summaries => { + _populateHashMaps(summaries); + renderGames(); + }).catch(() => {}); + renderGames(); + await refreshFirmwareFilters(); + await refreshConvQueuePanel(); + const conversionQueue = $('conv-queue-panel'); + const gamesPage = $('page-games'); + if (conversionQueue && gamesPage) gamesPage.append(conversionQueue); + await refreshGameCatalogHealth(); + } catch (e) { + setStatus(e.message, 'error'); + } +} + +async function refreshGameCatalogHealth() { + if (!window.pork.gameCatalogHealth || !$('game-catalog-health')) return; + try { + const health = await window.pork.gameCatalogHealth(); + $('catalog-health-complete').textContent = `${Number(health.complete || 0)} / ${Number(health.total || 0)}`; + $('catalog-health-missing').textContent = String(Number(health.missing_metadata || 0)); + $('catalog-health-artwork').textContent = String(Number(health.missing_artwork || 0)); + const scan = health.scan || {}; + $('catalog-health-scan').textContent = scan.running ? 'Scanning…' : (scan.lastError ? 'Scan issue' : 'Automatic'); + $('catalog-health-scan-detail').textContent = scan.lastError + ? String(scan.lastError) + : (scan.lastCompleted + ? window.tf('games.health.lastScan', 'last scan {date}', { date: new Date(scan.lastCompleted).toLocaleString(window.i18n?.current || 'en') }) + : window.t('games.health.interval', 'source check every 15 min')); + } catch (_) {} +} + +window.pork.on('local:scan:started', refreshGameCatalogHealth); +window.pork.on('local:scan:complete', () => refreshGameCatalogHealth()); +window.pork.on('prospero:queue:done', () => refreshGameCatalogHealth()); + +function gamePlatform(game) { + const explicit = String(game?.platform || '').toLowerCase(); + if (explicit === 'ps4' || explicit === 'ps5') return explicit; + const identity = [ + game?.game_id, + game?.content_id, + game?.prospero_content_id, + ].filter(Boolean).join(' ').toUpperCase(); + if (/CUSA\d{5}/.test(identity)) return 'ps4'; + if (/PPSA\d{5}/.test(identity)) return 'ps5'; + return 'unknown'; +} + +function versionedArtworkUrl(url, fetchedAt) { + const source = String(url || ''); + if (!source.startsWith('/pork-cache/')) return source; + const revision = Number(fetchedAt || 0); + return revision > 0 ? `${source}?v=${Math.trunc(revision)}` : source; +} + +function disconnectedDriveLabels(game) { + const labels = []; + for (const copy of Array.isArray(game?.localCopies) ? game.localCopies : []) { + const device = copy.device || copy.storageDevice || copy.volume?.device || {}; + const state = String(device.availabilityState || copy.volume?.availabilityState || copy.availabilityState || '').toLowerCase(); + if (!['unavailable', 'disconnected', 'offline', 'missing'].includes(state)) continue; + labels.push(device.friendlyLabel || device.displayName || device.label || device.stableFingerprint || copy.volume?.stableVolumeKey || 'external drive'); + } + return [...new Set(labels.map(String))]; +} + +function renderGamePlatformFilters() { + const counts = { all: state.games.length, ps4: 0, ps5: 0, unknown: 0 }; + for (const game of state.games) counts[gamePlatform(game)] += 1; + for (const platform of Object.keys(counts)) { + const count = $(`games-platform-count-${platform}`); + if (count) count.textContent = String(counts[platform]); + } + document.querySelectorAll('[data-game-platform]').forEach(button => { + const platform = button.dataset.gamePlatform; + const active = platform === state.platformFilter; + button.classList.toggle('active', active); + button.setAttribute('aria-pressed', String(active)); + if (platform === 'unknown') button.hidden = counts.unknown === 0 && !active; + }); +} + +function getFilteredGames() { + return state.games.filter(g => { + const q = state.search.toLowerCase(); + if (q && !(g.title || '').toLowerCase().includes(q) && !(g.game_id || '').toLowerCase().includes(q) && + !(g.prospero_name || '').toLowerCase().includes(q)) return false; + if (state.platformFilter !== 'all' && gamePlatform(g) !== state.platformFilter) return false; + // Status and firmware are independent filter dimensions — compute status as a + // boolean so both can compose (previously the status branches returned early, + // silently disabling the firmware filter). + let statusOk = true; + if (state.filter === 'installed') statusOk = window.chimeraLibraryModel.isInstalled(g); + else if (state.filter === 'backed_up') statusOk = window.chimeraLibraryModel.hasLocalCopy(g); + else if (state.filter === 'not_backed') statusOk = window.chimeraLibraryModel.needsProtection(g); + if (!statusOk) return false; + if (state.firmwareFilter !== 'all') { + const labels = (g.firmware_labels || '').split(',').filter(Boolean); + if (!labels.includes(state.firmwareFilter)) return false; + } + return true; + }).sort((a, b) => { + const sortValue = game => state.sortCol === 'title' + ? (game.prospero_name || game.title || game.game_id || '') + : (game[state.sortCol] ?? ''); + let av = sortValue(a), bv = sortValue(b); + if (typeof av === 'string') av = av.toLowerCase(); + if (typeof bv === 'string') bv = bv.toLowerCase(); + if (av === bv) return 0; + return state.sortDir === 'asc' ? (av > bv ? 1 : -1) : (av < bv ? 1 : -1); + }); +} + +function renderGames() { + renderGamePlatformFilters(); + const allGames = getFilteredGames(); + const list = allGames.slice(0, state.gameRenderLimit); + const tbody = $('games-tbody'); + const grid = $('games-grid'); + const tableWrap = $('games-table-wrap'); + const empty = $('games-empty'); + const emptyDetail = $('games-empty-detail'); + const emptyScan = $('btn-games-empty-scan'); + const emptyReset = $('btn-games-empty-reset'); + const loadMore = $('btn-games-load-more'); + const resultCount = $('games-result-count'); + const filtersActive = Boolean( + state.games.length && ( + state.search || + state.filter !== 'all' || + state.platformFilter !== 'all' || + state.firmwareFilter !== 'all' + ) + ); + + tbody.innerHTML = ''; + grid.innerHTML = ''; + empty.hidden = allGames.length !== 0; + emptyDetail.textContent = filtersActive + ? window.t('games.empty.filtered', 'No games match the selected platform and filters.') + : window.t('games.empty.detail', 'Connect the PS5 or scan the configured source folders.'); + emptyScan.hidden = filtersActive; + emptyReset.hidden = !filtersActive; + grid.hidden = !allGames.length || state.gameView !== 'grid'; + tableWrap.hidden = !allGames.length || state.gameView !== 'table'; + loadMore.hidden = list.length >= allGames.length; + resultCount.textContent = allGames.length + ? window.tf('games.resultCount', '{visible} of {total}', { visible: list.length, total: allGames.length }) + : ''; + document.querySelectorAll('[data-game-view]').forEach(button => { + const active = button.dataset.gameView === state.gameView; + button.classList.toggle('active', active); + button.setAttribute('aria-pressed', String(active)); + }); + + for (const g of list) { + const tr = document.createElement('tr'); + tr.className = 'game-row'; + tr.dataset.id = g.game_id; + // Compact status cell: readable tags instead of cryptic symbols + const patchesOnly = g.backup_count === 0 && g.backpork_count > 0; + const _hs = _gameHashSummary.get(g.game_id); + const hashTag = _hs + ? _hs.community_mismatch + ? `⚠ Hash Fail` + : _hs.community_matches > 0 + ? `✓ Verified` + : `⧭ Hashed` + : ''; + const fwLabels = (g.firmware_labels || '').split(',').filter(Boolean); + const patchedTag = fwLabels.length + ? `🔧 ${escHtml(fwLabels.join(', '))}` + : ''; + const shadowTag = g.shadow_mount + ? `◉ Mount ${escHtml(g.shadow_mount.state)}` + : ''; + const metadataTag = `Meta ${escHtml(g.metadata?.source || '?')}`; + const variantTag = g.regional_variants?.length + ? `${g.regional_variants.length} ${escHtml(g.regional_variants.length === 1 ? window.t('games.region', 'region') : window.t('games.regions', 'regions'))}` + : ''; + const platform = gamePlatform(g); + const platformLabel = platform === 'unknown' + ? window.t('games.platform.unknown', 'Unknown') + : platform.toUpperCase(); + const platformTag = `${escHtml(platformLabel)}`; + const requiredDrives = disconnectedDriveLabels(g); + const requiredDriveTag = requiredDrives.length + ? `${escHtml(window.t('games.requiredDrive', 'Needs drive'))}: ${escHtml(requiredDrives.join(', '))}` + : ''; + const statusHtml = `
    + ${platformTag} + ${requiredDriveTag} + ${window.chimeraLibraryModel.isInstalled(g) ? `${escHtml(window.t('games.status.installed', 'Installed'))}` : ''} + ${g.backup_count > 0 ? `${g.backup_count} bkp` : ''} + ${window.chimeraLibraryModel.hasLocalCopy(g) ? `✓` : ''} + ${patchesOnly ? `${escHtml(window.t('games.status.patchesOnly', 'Patches only'))}` : ''} + ${patchedTag} + ${shadowTag} + ${hashTag} + ${metadataTag} + ${variantTag} +
    `; + const displayTitle = g.prospero_name || g.title || g.game_id; + const metadataLine = [ + g.prospero_publisher, + g.prospero_region, + g.content_id || g.prospero_content_id, + ].filter(Boolean).join(' · '); + const coverUrl = g.prospero_cover_url || ''; + const renderedCoverUrl = versionedArtworkUrl(coverUrl, g.cover_fetched_at); + const iconFallbackAttribute = g.prospero_icon_url + ? ` data-fallback-src="${escHtml(g.prospero_icon_url)}"` + : ''; + const coverPlaceholder = ``; + const cover = renderedCoverUrl + ? `
    + ${escHtml(displayTitle)} + ${coverPlaceholder} +
    ` + : g.prospero_icon_url + ? ` +
    + ${escHtml(platformLabel)} + ${escHtml(displayTitle)} + ${coverPlaceholder} +
    ${escHtml(displayTitle)}${escHtml(g.game_id)}
    +
    ` + : `
    ${escHtml(platformLabel)}${escHtml(displayTitle)}${escHtml(g.game_id || '?')}
    `; + const metadataSynced = Boolean(g.prospero_name && g.prospero_icon_url && g.prospero_cover_url); + const metadataState = metadataSynced + ? window.t('games.metadata.synced', 'Metadata: synced') + : window.t('games.metadata.enriching', 'Metadata: enriching'); + const card = document.createElement('article'); + card.className = 'game-card'; + card.dataset.id = g.game_id; + card.dataset.platform = platform; + card.style.setProperty('--card-hue', String(titleHue(g.game_id))); + card.tabIndex = 0; + card.innerHTML = ` +
    ${cover} + ${metadataSynced ? '' : `${escHtml(metadataState)}`} +
    + ${patchesOnly ? '' : ``} + +
    +
    +
    +
    + ${escHtml(displayTitle)} +
    + ${escHtml([g.game_id, metadataLine].filter(Boolean).join(' · '))} + ${statusHtml} +
    `; + const nativeCover = card.querySelector('.game-card-cover-case.has-native-cover .game-card-cover'); + if (nativeCover) { + const applyNaturalCoverRatio = () => { + const width = Number(nativeCover.naturalWidth || 0); + const height = Number(nativeCover.naturalHeight || 0); + const ratio = height > 0 ? width / height : 0; + if (width >= 160 && height >= 240 && ratio >= 0.52 && ratio <= 0.86) { + card.style.setProperty('--game-cover-ratio', `${width} / ${height}`); + } + }; + if (nativeCover.complete) applyNaturalCoverRatio(); + else nativeCover.addEventListener('load', applyNaturalCoverRatio, { once: true }); + } + grid.appendChild(card); + if (patchesOnly) tr.classList.add('game-row-patches-only'); + tr.innerHTML = ` + +
    + ${renderedCoverUrl || g.prospero_icon_url + ? `${escHtml(window.t('games.coverFor', 'Cover for'))} ${escHtml(g.prospero_name || g.title || g.game_id)}` + : '
    '} + ${renderedCoverUrl || g.prospero_icon_url ? '' : ''} + + ${escHtml(g.prospero_name || g.title || g.game_id)} + ${escHtml([ + g.prospero_publisher, + g.prospero_region, + g.content_id || g.prospero_content_id, + ].filter(Boolean).join(' · ') || window.t('games.metadataQueued', 'Metadata is being enriched…'))} + +
    + + ${escHtml(g.game_id)} + ${escHtml(g.version || g.prospero_version || '—')} + ${g.backup_size ? fmt(g.backup_size) : (g.size ? fmt(g.size) : (g.prospero_size || '—'))} + ${renderFwPills(g.firmware_labels)} + ${statusHtml} + + ${patchesOnly + ? `⚠ ${escHtml(window.t('games.noBackup', 'No game backup'))}` + : ` + ` + } + + `; + tbody.appendChild(tr); + } + + // Click delegation: action buttons take priority; otherwise the card/row opens detail. + const handleLibraryClick = async e => { + const btn = e.target.closest('[data-action]'); + if (btn) { + try { + if (btn.dataset.action === 'install') { + await openInstallModal(btn.dataset.id, 'pfs'); + } else if (btn.dataset.action === 'convert') { + const s = await window.pork.getSettings(); + const mode = (s.gameConversionMode === 'pfs' || !s.gameConversionMode) + ? (window.pork.isWebMode ? 'ffpfsc' : 'ffpkg') + : s.gameConversionMode; + await openInstallModal(btn.dataset.id, mode); + } else if (btn.dataset.action === 'metadata') { + await editGameMetadata(btn.dataset.id); + } + } catch (error) { + setStatus(error.message, 'error'); + showToast(error.message, 'error', 9000); + } + return; + } + const item = e.target.closest('[data-id].game-row, [data-id].game-card'); + if (item) openModal(item.dataset.id); + }; + tbody.onclick = handleLibraryClick; + grid.onclick = handleLibraryClick; + grid.onkeydown = e => { + if ((e.key === 'Enter' || e.key === ' ') && !e.target.closest('button')) { + e.preventDefault(); + const card = e.target.closest('.game-card[data-id]'); + if (card) openModal(card.dataset.id); + } + }; + window.i18n?.apply(grid); + window.i18n?.apply(tbody); +} + +async function editGameMetadata(gameId) { + const game = state.games.find(item => item.game_id === gameId); + if (!game) return; + const hasOverride = Boolean(game.metadata?.overriddenFields?.length); + if (hasOverride && await showExactConfirmation('Clear manual metadata and return to online and local metadata management?', 'CLEAR METADATA')) { + await window.pork.updateGameMetadata(gameId, {}); + await loadGames(); + setStatus(`Handmatige metadata voor ${gameId} gewist.`, 'ok'); + return; + } + const ask = (label, value = '') => showPrompt(`${label} voor ${gameId}:`, String(value || '')); + const title = await ask('Volledige titel', game.prospero_name || game.title); + if (title == null) return; + const publisher = await ask('Uitgever', game.prospero_publisher); + if (publisher == null) return; + const region = await ask('Regio', game.prospero_region); + if (region == null) return; + const iconUrl = await ask(window.t?.('games.metadata.iconUrl', 'HTTPS image URL') || 'HTTPS image URL', game.prospero_icon_url); + if (iconUrl == null) return; + if (iconUrl && !/^(https:\/\/|\/api\/local-artwork\/)/i.test(iconUrl)) { + throw new Error(window.t?.('games.metadata.invalidImage', 'Use an HTTPS URL or a local Chimera image.') || 'Use an HTTPS URL or a local Chimera image.'); + } + const coverUrl = await ask(window.t?.('games.metadata.coverUrl', 'HTTPS portrait cover URL') || 'HTTPS portrait cover URL', game.prospero_cover_url); + if (coverUrl == null) return; + if (coverUrl && !/^(https:\/\/|\/api\/local-artwork\/)/i.test(coverUrl)) { + throw new Error(window.t?.('games.metadata.invalidImage', 'Use an HTTPS URL or a local Chimera image.') || 'Use an HTTPS URL or a local Chimera image.'); + } + const description = await ask(window.t?.('games.metadata.description', 'Description') || 'Description', game.prospero_description); + if (description == null) return; + await window.pork.updateGameMetadata(gameId, { title, publisher, region, iconUrl, coverUrl, description }); + await loadGames(); + setStatus(`Metadata voor ${gameId} handmatig vastgezet.`, 'success'); +} + +// Sorting +document.querySelectorAll('#games-table th[data-col]').forEach(th => { + th.addEventListener('click', () => { + if (state.sortCol === th.dataset.col) state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc'; + else { state.sortCol = th.dataset.col; state.sortDir = 'asc'; } + localStorage.setItem('chimera.games.sortCol', state.sortCol); + localStorage.setItem('chimera.games.sortDir', state.sortDir); + renderGames(); + }); +}); + +// Filter buttons +document.querySelectorAll('.fbtn').forEach(btn => + btn.addEventListener('click', () => { + document.querySelectorAll('.fbtn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + state.filter = btn.dataset.filter; + state.gameRenderLimit = 160; + localStorage.setItem('chimera.games.filter', state.filter); + renderGames(); + }) +); + +document.querySelectorAll('[data-game-platform]').forEach(button => { + button.addEventListener('click', () => { + state.platformFilter = ['ps4', 'ps5', 'unknown'].includes(button.dataset.gamePlatform) + ? button.dataset.gamePlatform + : 'all'; + state.gameRenderLimit = 160; + localStorage.setItem('chimera.games.platform', state.platformFilter); + renderGames(); + }); +}); + +// Search +$('games-search').addEventListener('input', e => { + state.search = e.target.value; + state.gameRenderLimit = 160; + renderGames(); +}); + +document.querySelectorAll('[data-game-view]').forEach(button => { + button.addEventListener('click', () => { + state.gameView = button.dataset.gameView === 'table' ? 'table' : 'grid'; + localStorage.setItem('chimera.games.view', state.gameView); + renderGames(); + }); +}); + +$('btn-games-load-more')?.addEventListener('click', () => { + state.gameRenderLimit += 160; + renderGames(); +}); + +$('btn-games-empty-scan')?.addEventListener('click', () => $('btn-rescan-game-sources')?.click()); +$('btn-games-empty-reset')?.addEventListener('click', () => { + state.search = ''; + state.filter = 'all'; + state.platformFilter = 'all'; + state.firmwareFilter = 'all'; + state.gameRenderLimit = 160; + $('games-search').value = ''; + localStorage.setItem('chimera.games.filter', 'all'); + localStorage.setItem('chimera.games.platform', 'all'); + document.querySelectorAll('.fwbtn').forEach(button => { + button.classList.toggle('active', button.dataset.fw === 'all'); + }); + renderGames(); +}); + +document.querySelectorAll('.fbtn').forEach(button => { + button.classList.toggle('active', button.dataset.filter === state.filter); +}); + +window.addEventListener('chimera:language-changed', () => renderGames()); + diff --git a/renderer/modules/hash.js b/renderer/modules/hash.js new file mode 100644 index 0000000..74a7e07 --- /dev/null +++ b/renderer/modules/hash.js @@ -0,0 +1,164 @@ +// ── Hash verification ────────────────────────────────────────────────────────── +let hashingInProgress = false; + +window.pork.on('hash:progress', ({ percent }) => { + const fill = $('hash-progress-fill'); + const label = $('hash-progress-label'); + if (fill) fill.style.width = `${percent}%`; + if (label) label.textContent = `Hashing… ${percent}%`; +}); + +async function renderHashSection(game_id, backups) { + const section = $('modal-hash-section'); + const badge = $('modal-hash-community-badge'); + badge.hidden = true; + badge.className = 'hash-badge'; // reset class each render + + // Include both local and backpork backups — all game files deserve verification. + if (!backups.length) { section.hidden = true; return; } + section.hidden = false; + + const { localHashes, communityMatches, communityMismatch } = await window.pork.hashStatus(game_id); + const communitySet = new Set(communityMatches.map(h => h.file_hash)); + + // Badge above hash list: mismatch warning takes priority over verified check. + const allInCommunity = localHashes.length > 0 && communityMatches.length === localHashes.length; + if (communityMismatch && localHashes.length > 0) { + badge.hidden = false; + badge.className = 'hash-badge hash-mismatch'; + badge.textContent = '⚠ Community Hash Mismatch — files may be modified or contain brick code'; + } else if (allInCommunity) { + badge.hidden = false; + badge.className = 'hash-badge hash-community'; + badge.textContent = 'Community Verified ✓'; + } + + // Each backup is a folder — show aggregate hash status for the whole folder. + $('modal-hash-list').innerHTML = backups.map(b => { + const folderName = b.backup_path.replace(/\\/g, '/').split('/').pop(); + const hashedCount = localHashes.length; + const communityCount = communityMatches.length; + + if (hashedCount === 0) { + return ` +
    + +
    + +
    +
    `; + } + + const allCommunity = communityCount === hashedCount; + const statusBadge = allCommunity + ? `${communityCount} file${communityCount !== 1 ? 's' : ''} community ✓` + : communityMismatch + ? `⚠ Hash Mismatch — ${hashedCount} local hash${hashedCount !== 1 ? 'es' : ''} don't match community list` + : `${hashedCount} file${hashedCount !== 1 ? 's' : ''} hashed` + + (communityCount > 0 ? ` ${communityCount} community ✓` : ''); + + return ` +
    + +
    + +
    +
    `; + }).join(''); +} + +async function runHash(backup_path, game_id, verified = 1, hash_type = 'game') { + if (hashingInProgress) return; + hashingInProgress = true; + + // Disable all hash buttons while running + $('modal-hash-list').querySelectorAll('button').forEach(b => b.disabled = true); + + const prog = $('modal-hash-progress'); + const fill = $('hash-progress-fill'); + fill.style.width = '0%'; + prog.hidden = false; + + try { + const { hashes } = await window.pork.hashCompute(backup_path, game_id); + let communityCount = 0; + for (const h of hashes) { + const { communityMatch } = await window.pork.hashAdd({ + game_id, file_hash: h.file_hash, file_size: h.file_size, backup_path: h.backup_path, verified, hash_type, + }); + if (communityMatch) communityCount++; + } + + prog.hidden = true; + if (communityCount > 0) { + setStatus(`${hashes.length} file(s) hashed — ${communityCount} community match(es) ✓`, 'ok'); + } else { + setStatus(`${hashes.length} file(s) hashed — will appear in the next community update`, 'ok'); + } + + // Re-render the hash section with fresh data + const backups = await window.pork.listBackups(game_id); + await renderHashSection(game_id, backups); + } catch (e) { + prog.hidden = true; + setStatus(`Hash failed: ${e.message}`, 'error'); + // Re-enable buttons on error + $('modal-hash-list').querySelectorAll('button').forEach(b => b.disabled = false); + } finally { + hashingInProgress = false; + } +} + +// Hash button delegation — attached once on the hash list container +$('modal-hash-list').addEventListener('click', async e => { + if (hashingInProgress) return; + + // ── Hash & Add (not yet in DB) ───────────────────────────────────────────── + const addBtn = e.target.closest('.hash-btn-add'); + if (addBtn) { + await runHash(addBtn.dataset.path, addBtn.dataset.game, 1, addBtn.dataset.source === 'backpork' ? 'backpork' : 'game'); + return; + } + + // ── Re-verify (already in DB) — show inline confirm first ───────────────── + const reverifyBtn = e.target.closest('.hash-btn-reverify'); + if (reverifyBtn) { + const statusEl = reverifyBtn.closest('.hash-entry-status'); + const path = reverifyBtn.dataset.path; + const game = reverifyBtn.dataset.game; + const source = reverifyBtn.dataset.source; + + // Replace button with inline confirm + reverifyBtn.style.display = 'none'; + const confirm = document.createElement('div'); + confirm.className = 'hash-confirm'; + confirm.innerHTML = ` + Game works? + + `; + statusEl.appendChild(confirm); + + confirm.querySelector('.hash-confirm-yes').addEventListener('click', async () => { + confirm.remove(); + await runHash(path, game, 1, source === 'backpork' ? 'backpork' : 'game'); + }); + confirm.querySelector('.hash-confirm-no').addEventListener('click', () => { + confirm.remove(); + reverifyBtn.style.display = ''; + }); + return; + } +}); + diff --git a/renderer/modules/helpers.js b/renderer/modules/helpers.js new file mode 100644 index 0000000..39fc8ce --- /dev/null +++ b/renderer/modules/helpers.js @@ -0,0 +1,199 @@ +// ── Accent color picker ──────────────────────────────────────────────────────── +$('s-accent-color').addEventListener('input', async e => { + const hex = e.target.value; + applyAccent(hex); + $('s-accent-hex').value = hex.toUpperCase(); + await window.pork.setSettings({ accentColor: hex }); +}); + +// Hex text field — let user type a hex value directly +$('s-accent-hex').addEventListener('input', e => { + const raw = e.target.value.trim(); + const hex = raw.startsWith('#') ? raw : '#' + raw; + if (/^#[0-9a-fA-F]{6}$/.test(hex)) { + applyAccent(hex); + window.pork.setSettings({ accentColor: hex }); + } +}); + +$('btn-reset-accent').addEventListener('click', async () => { + const def = '#CBBAFF'; + applyAccent(def); + await window.pork.setSettings({ accentColor: def }); + setStatus('Accent color reset to default', 'ok'); +}); + +// Confirm dialog helper +function showConfirm(message) { + return new Promise(resolve => { + $('confirm-message').textContent = message; + openAccessibleDialog($('confirm-overlay'), $('confirm-ok')); + + function cleanup(result) { + closeAccessibleDialog($('confirm-overlay')); + $('confirm-ok').removeEventListener('click', onOk); + $('confirm-cancel').removeEventListener('click', onCancel); + $('confirm-overlay').removeEventListener('keydown', onKey); + resolve(result); + } + const onOk = () => cleanup(true); + const onCancel = () => cleanup(false); + const onKey = event => { if (event.key === 'Escape') onCancel(); }; + $('confirm-ok').addEventListener('click', onOk); + $('confirm-cancel').addEventListener('click', onCancel); + $('confirm-overlay').addEventListener('keydown', onKey); + }); +} + +// Prompt dialog helper +function showPrompt(message, defaultValue = '', options = {}) { + return new Promise(resolve => { + $('prompt-message').textContent = message; + $('prompt-input').value = defaultValue; + $('prompt-dialog').classList.toggle('prompt-dialog-danger', options.tone === 'danger'); + $('prompt-ok').className = `btn ${options.tone === 'danger' ? 'btn-danger' : 'btn-accent'}`; + $('prompt-ok').textContent = options.confirmLabel || (typeof window.t === 'function' ? window.t('common.ok', 'OK') : 'OK'); + $('prompt-cancel').textContent = typeof window.t === 'function' ? window.t('common.cancel', 'Cancel') : 'Cancel'; + openAccessibleDialog($('prompt-overlay'), $('prompt-input')); + + function cleanup(result) { + closeAccessibleDialog($('prompt-overlay')); + $('prompt-ok').removeEventListener('click', onOk); + $('prompt-cancel').removeEventListener('click', onCancel); + $('prompt-input').removeEventListener('keydown', onKey); + resolve(result); + } + function onOk() { cleanup($('prompt-input').value); } + function onCancel() { cleanup(null); } + function onKey(e) { if (e.key === 'Enter') onOk(); else if (e.key === 'Escape') onCancel(); } + $('prompt-ok').addEventListener('click', onOk); + $('prompt-cancel').addEventListener('click', onCancel); + $('prompt-input').addEventListener('keydown', onKey); + }); +} + +async function showExactConfirmation(message, phrase) { + const isDutch = window.i18n?.current === 'nl'; + const englishPhrases = { VERWIJDER: 'DELETE', 'VERWIJDER VAN PS5': 'DELETE FROM PS5', UITSCHAKELEN: 'DISABLE', INTREKKEN: 'REVOKE', HERSTEL: 'RESTORE', 'PAS ETAHEN TOE': 'APPLY ETAHEN', 'WEK PS5': 'WAKE PS5' }; // i18n: server confirmation tokens + const visiblePhrase = isDutch ? phrase : (englishPhrases[phrase] || phrase); + const instruction = isDutch ? `Typ exact ${visiblePhrase} om door te gaan:` : `Type ${visiblePhrase} exactly to continue:`; + const entered = await showPrompt(`${message}\n\n${instruction}`, '', { + tone: 'danger', + confirmLabel: isDutch ? 'Bevestigen' : 'Confirm', + }); + if (entered === visiblePhrase) return phrase; + if (entered !== null) showToast(isDutch ? 'Actie geannuleerd: de bevestiging kwam niet exact overeen.' : 'Action cancelled: the confirmation phrase did not match exactly.', 'info'); + return null; +} + +// Destination picker for upload and conversion + +function showDestinationPicker(paths) { + return new Promise(resolve => { + const list = $('dest-picker-list'); + list.innerHTML = paths.map((p, i) => ` + + `).join(''); + openAccessibleDialog($('dest-picker-overlay'), list.querySelector('.dest-picker-option')); + + let resolved = false; + function cleanup(result) { + if (resolved) return; + resolved = true; + closeAccessibleDialog($('dest-picker-overlay')); + // #dest-picker-cancel is a persistent element; remove our handler so it + // doesn't accumulate across opens when the user picks an option instead. + $('dest-picker-cancel').removeEventListener('click', onCancel); + $('dest-picker-overlay').removeEventListener('keydown', onKey); + resolve(result); + } + const onCancel = () => cleanup(null); + const onKey = event => { if (event.key === 'Escape') onCancel(); }; + // Option buttons live in list.innerHTML, which is re-rendered each open, so + // their listeners die with the old DOM — no cleanup needed for those. + list.querySelectorAll('.dest-picker-option').forEach((btn, i) => { + btn.addEventListener('click', () => cleanup(paths[i]), { once: true }); + }); + $('dest-picker-cancel').addEventListener('click', onCancel); + $('dest-picker-overlay').addEventListener('keydown', onKey); + }); +} + +// Ensure a game has an ftp_path set (prompting if needed). Returns the path or null if cancelled. +async function ensureGameFtpPath(gameId) { + const g = state.currentGame?.game_id === gameId ? state.currentGame + : (await window.pork.listGames({})).find(x => x.game_id === gameId); + + if (g?.ftp_path) return g.ftp_path; + + // No stored path — pick one + let basePath = null; + if (remoteGamePaths.length === 0) { + // No locations configured — use default, but don't store (user hasn't set up locations) + return `/mnt/sandbox/pfsmnt/${gameId}-app0`; + } else if (remoteGamePaths.length === 1) { + basePath = remoteGamePaths[0].path; + } else { + const chosen = await showDestinationPicker(remoteGamePaths); + if (!chosen) return null; + basePath = chosen.path; + } + + const ftp_path = basePath.replace(/\/?$/, '/') + gameId + '-app0'; + await window.pork.updateGame({ game_id: gameId, fields: { ftp_path } }); + if (state.currentGame?.game_id === gameId) { + state.currentGame = { ...state.currentGame, ftp_path }; + renderManageInstallLocation(state.currentGame); + } + return ftp_path; +} + +$('btn-refresh-all-meta').addEventListener('click', async () => { + const ok = await showConfirm( + 'This will clear all cached Prospero metadata and re-fetch every game from scratch. ' + + 'Background fetches will queue automatically. Continue?' + ); + if (!ok) return; + + $('btn-refresh-all-meta').disabled = true; + setStatus('Queueing metadata refresh for all games…'); + try { + const { queued } = await window.pork.prosperoRefreshAll(); + setStatus(`Refreshing metadata for ${queued} game(s) in background…`, 'ok'); + } catch (e) { + setStatus(`Refresh failed: ${e.message}`, 'error'); + } finally { + $('btn-refresh-all-meta').disabled = false; + } +}); + +// Firmware helpers +function renderFwPills(firmware_labels) { + if (!firmware_labels) return '—'; + return firmware_labels.split(',').filter(Boolean) + .map(l => `🔧 ${escHtml(l)}`).join(''); +} + +async function refreshFirmwareFilters() { + const labels = await window.pork.firmwareLabels(); + const row = $('firmware-filter-row'); + const cont = $('firmware-filter-btns'); + + if (!labels.length) { row.hidden = true; return; } + row.hidden = false; + + cont.innerHTML = `` + + labels.map(l => ``).join(''); + + cont.onclick = e => { + const btn = e.target.closest('.fwbtn'); + if (!btn) return; + state.firmwareFilter = btn.dataset.fw; + cont.querySelectorAll('.fwbtn').forEach(b => b.classList.toggle('active', b.dataset.fw === state.firmwareFilter)); + renderGames(); + }; +} + diff --git a/renderer/modules/hotkeys.js b/renderer/modules/hotkeys.js new file mode 100644 index 0000000..451c72d --- /dev/null +++ b/renderer/modules/hotkeys.js @@ -0,0 +1,103 @@ +// ── System View hotkeys ──────────────────────────────────────────────────────── +document.addEventListener('keydown', e => { + // Escape always exits in-app fullscreen, regardless of active page + if (e.key === 'Escape' && $('sv-viewer-wrap')?.classList.contains('sv-viewer-wrap--fullscreen')) { + e.preventDefault(); + svToggleFullscreen(); + return; + } + if (!$('page-system-view')?.classList.contains('active')) return; + const tag = document.activeElement?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + const k = e.key.toLowerCase(); + if (k === _svHotkeys.mute) { e.preventDefault(); $('btn-sv-mute').click(); } + else if (k === _svHotkeys.gif) { e.preventDefault(); $('btn-sv-record').click(); } + else if (k === _svHotkeys.video) { e.preventDefault(); $('btn-sv-vid-rec').click(); } + else if (k === _svHotkeys.fullscreen) { e.preventDefault(); svToggleFullscreen(); } + else if (k === _svHotkeys.popout) { e.preventDefault(); $('btn-sv-popout').click(); } +}); + +// Auto-scan toast on launch +window.pork.on('local:scan:complete', async ({ total, added }) => { + if (added > 0) { + showToast(`Auto-scan: found ${added} new game${added !== 1 ? 's' : ''} (${total} total) — fetching metadata…`, 'ok'); + } else if (total > 0) { + showToast(`Auto-scan: ${total} game${total !== 1 ? 's' : ''} already tracked`, 'info'); + } + // Always refresh the active page so pruned/stale game rows clear from the UI + // even when 0 games were found this scan. + const activePage = document.querySelector('.page.active')?.id; + if (activePage === 'page-dashboard') loadDashboard(); + if (activePage === 'page-games') loadGames(); + if (activePage === 'page-backups') loadBackups(); +}); + +// Toast when Prospero background queue finishes +window.pork.on('prospero:queue:done', ({ fetched, failed }) => { + if (fetched > 0 && failed === 0) { + showToast(`Metadata fetched for ${fetched} game${fetched !== 1 ? 's' : ''}`, 'ok'); + } else if (fetched > 0) { + showToast(`Metadata fetched for ${fetched} game${fetched !== 1 ? 's' : ''} (${failed} failed)`, 'info'); + } else if (failed > 0) { + showToast(`Metadata fetch failed for ${failed} game${failed !== 1 ? 's' : ''}`, 'error', 8000); + } +}); + +// Live-refresh modal when background fetch completes +window.pork.on('prospero:updated', async ({ game_id, data }) => { + // Reload games list in background so future opens have fresh data + state.games = await window.pork.listGames({}); + renderGames(); // Redraw table so icon/name/metadata show without requiring a page nav + + // Update cheat icon map and any visible cheat card for this game + if (data.iconUrl) { + _cheatIconMap.set(game_id, data.iconUrl); + document.querySelectorAll(`.cheat-card[data-cusa="${game_id}"]`).forEach(card => { + const empty = card.querySelector('.cheat-card-icon-empty'); + if (!empty) return; + const img = document.createElement('img'); + img.className = 'cheat-card-icon'; + img.alt = ''; + img.src = data.iconUrl; + img.onerror = () => { img.style.display = 'none'; }; + empty.replaceWith(img); + }); + } + + // If the modal is open for this game, refresh it + if (state.currentGame?.game_id === game_id) { + const updated = state.games.find(g => g.game_id === game_id); + if (updated) { + state.currentGame = updated; + $('modal-title').textContent = updated.prospero_name || updated.title || game_id; + $('modal-subtitle').textContent = `${updated.game_id} · ${updated.prospero_region || (updated.version ? 'v' + updated.version : '')}`; + + // Refresh info table with new metadata + const patches = tryParseJson(updated.prospero_patches, []); + const fwVersions = [...new Set(patches.map(p => p.requiredFirmware).filter(Boolean))]; + const dlc = tryParseJson(updated.prospero_dlc, []); + const infoRows = [ + ['Game ID', updated.game_id], + ['Content ID', updated.content_id || updated.prospero_content_id || '—'], + ['Version', updated.version || updated.prospero_version || '—'], + ['Size', updated.backup_size ? fmt(updated.backup_size) : (updated.size ? fmt(updated.size) : (updated.prospero_size || '—'))], + ['Installed', updated.installed ? 'Yes' : 'No'], + ['Backed Up', updated.backed_up ? 'Yes' : 'No'], + ['Prepared for firmware', updated.porked_firmware ? `${updated.porked_firmware}${updated.porked_at ? ' (' + new Date(updated.porked_at).toLocaleDateString() + ')' : ''}` : '—'], + ['Publisher', updated.prospero_publisher || '—'], + ['Publisher ID', updated.prospero_publisher_id || '—'], + ['Region', updated.prospero_region || '—'], + ['Last Updated', updated.prospero_last_updated || '—'], + ['Required Firmware', fwVersions.length ? fwVersions.join(', ') : '—'], + ['DLC Available', dlc.length ? `${dlc.length} item(s)` : 'None'], + ['FTP Path', updated.ftp_path || '—'], + ['Last Scanned', updated.last_scanned ? new Date(updated.last_scanned).toLocaleString() : '—'], + ['Metadata Age', updated.prospero_fetched_at ? fmtAge(Date.now() - updated.prospero_fetched_at) : 'Never fetched'], + ]; + $('modal-info').innerHTML = infoRows.map(([k, v]) => `${escHtml(k)}${escHtml(v)}`).join(''); + + renderProsperoData(updated); + } + } +}); + diff --git a/renderer/modules/i18n.js b/renderer/modules/i18n.js new file mode 100644 index 0000000..6d8704e --- /dev/null +++ b/renderer/modules/i18n.js @@ -0,0 +1,84 @@ +// ── Chimera i18n engine ─────────────────────────────────────────────────────── +// Loaded first so window.t() is available to all modules. +// Call window.i18n.load(code) to fetch a lang file and re-apply all translations. + +window.chimeraI18nReady = new Promise(resolve => { + window.chimeraResolveI18nReady = resolve; +}); + +window.i18n = { + current: 'en', + strings: {}, + _observer: null, + + /** Translate a key. Falls back to `fallback` if provided, then the key itself. */ + t(key, fallback) { + const v = this.strings[key]; + if (v !== undefined) return v; + return fallback !== undefined ? fallback : key; + }, + + /** Walk every [data-i18n] element and set its textContent to the translated string. + * If no translation exists for a key the original text is left unchanged. */ + apply(root = document) { + document.documentElement.lang = this.current || 'en'; + const select = selector => [ + ...(root.matches?.(selector) ? [root] : []), + ...(root.querySelectorAll?.(selector) || []), + ]; + select('[data-i18n]').forEach(el => { + const val = this.strings[el.dataset.i18n]; + if (val !== undefined) el.textContent = val; + }); + select('[data-i18n-placeholder]').forEach(el => { + const val = this.strings[el.dataset.i18nPlaceholder]; + if (val !== undefined) el.placeholder = val; + }); + select('[data-i18n-title]').forEach(el => { + const val = this.strings[el.dataset.i18nTitle]; + if (val !== undefined) el.title = val; + }); + select('[data-i18n-aria-label]').forEach(el => { + const val = this.strings[el.dataset.i18nAriaLabel]; + if (val !== undefined) el.setAttribute('aria-label', val); + }); + if (!this._observer && document.body) { + this._observer = new MutationObserver(records => { + for (const record of records) { + for (const node of record.addedNodes) { + if (node.nodeType === Node.ELEMENT_NODE) this.apply(node); + } + } + }); + this._observer.observe(document.body, { childList: true, subtree: true }); + } + }, + + /** Load a language file by code (e.g. "en", "fr", "de") via IPC, then apply. */ + async load(code) { + if (!code) code = 'en'; + try { + const data = await window.pork.langLoad(code); + if (data && typeof data.strings === 'object') { + this.strings = data.strings; + this.current = data.meta?.code || code; + } + } catch (e) { + console.warn('[i18n] Could not load language "' + code + '":', e.message || e); + // Fall back to empty (original HTML text remains visible) + this.strings = {}; + this.current = 'en'; + } + this.apply(); + window.dispatchEvent(new CustomEvent('chimera:language-changed', { + detail: { code: this.current }, + })); + }, +}; + +/** Shorthand global translate function */ +window.t = (key, fallback) => window.i18n.t(key, fallback); +window.tf = (key, fallback, values = {}) => Object.entries(values).reduce( + (text, [name, value]) => text.replaceAll(`{${name}}`, String(value)), + window.i18n.t(key, fallback), +); diff --git a/renderer/modules/import-wizard.js b/renderer/modules/import-wizard.js new file mode 100644 index 0000000..42c075d --- /dev/null +++ b/renderer/modules/import-wizard.js @@ -0,0 +1 @@ +"use strict";!function(t,e){const a=e();"object"==typeof module&&module.exports&&(module.exports=a),t?.document&&t.addEventListener("DOMContentLoaded",()=>a.mount(t))}("undefined"!=typeof window?window:null,function(){const t=["upload","inspect","recipe","destination","finalize"];function e(t){return Array.from(t||[]).map(t=>{return{file:t,relativePath:(e=t.webkitRelativePath||t.name,String(e||"").replace(/\\/g,"/").replace(/^\/+/,"").split("/").filter(t=>t&&"."!==t&&".."!==t).join("/")),size:Number(t.size)};var e}).filter(t=>t.relativePath&&Number.isSafeInteger(t.size)&&t.size>0)}function a(t,e,a){return e?(t+(a?-1:1)+e)%e:-1}function i(t){const e=Array.isArray(t?.files)?t.files:[],a=e.reduce((t,e)=>t+Number(e.size||0),0),i=e.reduce((t,e)=>t+Number(e.offset??e.bytesReceived??0),0);return{resumable:["created","uploading"].includes(t?.state),expected:a,received:i,percent:a?Math.min(100,Math.round(i/a*100)):0}}function r(t){return 0!==t.step||t.selection?.length||t.session?t.step>=2&&"classified"!==t.classification?.status?"importWizard.reviewRequired":t.step>=3&&!t.destinationId?"importWizard.error.destination":4!==t.step||t.confirmed?null:"importWizard.error.confirm":"importWizard.error.selection"}const o=t=>String(t??"").replace(/[&<>"']/g,t=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[t])),s=t=>{const e=Number(t);if(!Number.isFinite(e))return"unknown";const a=["B","KiB","MiB","GiB","TiB"];let i=0,r=e;for(;r>=1024&&it.toString(16).padStart(2,"0")).join("")}return Object.freeze({deriveResumeState:i,mount:function(d){const l=d.pork;if(d.document.getElementById("chimera-import-open"))return;const c=(t,e)=>d.i18n?.t?.(t,e)||e,p={step:0,selection:[],session:null,job:null,classification:null,order:[],patchIds:new Set,artworkIds:new Set,installNotes:"",destinations:[],destinationId:"",preview:null,uploads:[],jobs:[],busy:!1,confirmed:!1,status:"",error:""};let m=null;const u=d.document.createElement("button");u.id="chimera-import-open",u.type="button",u.className="btn btn-accent chimera-import-launcher",u.style.cssText="position:fixed;right:22px;bottom:22px;z-index:1150",u.dataset.i18n="importWizard.open",u.textContent=c("importWizard.open","Managed import"),d.document.body.appendChild(u);const f=d.document.createElement("div");f.id="import-wizard-overlay",f.className="import-wizard-overlay",f.hidden=!0,f.innerHTML='',d.document.body.appendChild(f);const b=f.querySelector(".import-wizard-body"),v=f.querySelector(".import-wizard-status"),h=()=>({installNotes:p.installNotes,componentOrder:p.order,patchComponentIds:[...p.patchIds],artworkComponentIds:[...p.artworkIds]}),w=()=>p.classification?.components?.reduce((t,e)=>t+Number(e.size||0),0)||p.selection.reduce((t,e)=>t+e.size,0);function z(){const t=p.session?i(p.session):{percent:0,received:0,expected:w()};b.innerHTML=`

    Choose multiple files or a complete folder. Files stay quarantined until every validation succeeds.

    ${p.selection.length?`

    ${p.selection.length} files - ${s(w())}

    `:""}

    Reselect the same files to continue from the verified server offsets.

    ${[...p.uploads.filter(t=>i(t).resumable).map(t=>{const e=i(t);return`
    ${o(t.files?.[0]?.relativePath||t.id)}${e.percent}% - ${s(e.received)} / ${s(e.expected)}
    `}),...p.jobs.filter(t=>!["finalized","cancelled"].includes(t.state)).map(t=>`
    ${o(t.id)}${o(t.state)}
    `)].join("")||'

    No resumable imports.

    '}
    `}function g(){const t=p.classification,e=t?.issues||[];b.innerHTML=''+(t?`
    Status${o(t.status)}
    Components${t.components?.length||0}
    ${e.length?`
      ${e.map(t=>`
    • ${o(t.code)}
      ${o(t.message||t.detail||"")}
    • `).join("")}
    `:""}`:'

    No inspection evidence yet.

    ')}function y(){const t=p.classification?.components||[];b.innerHTML=`

    Structured component order

    ${p.order.map((e,a)=>{const i=t.find(t=>t.id===e);return`
    ${a+1}. ${o(i?.relativePath||e)}${o(i?.type?.value||"")} - ${o(i?.version?.value||"")}
    `}).join("")}

    Attach to portable manifest

    ${t.filter(t=>["patch_bundle","apr_ampr","image"].includes(t.type?.value)).map(t=>{const e="image"===t.type.value,a=e?p.artworkIds.has(t.id):p.patchIds.has(t.id);return``}).join("")||'

    No patch/APR or artwork components detected.

    '}
    `}function $(){const t=p.destinations.find(t=>t.id===p.destinationId);b.innerHTML=`
    Normalized path${o(p.preview?.destination||"-")}
    Required / available space${s(w())} / ${s(t?.availableBytes)}
    `}function W(){const t=p.destinations.find(t=>t.id===p.destinationId);b.innerHTML=`
    Title${o(p.preview?.titleName||p.classification?.title?.id?.value||"-")}
    Normalized path${o(p.preview?.destination||"-")}
    Volume${o(t?.label||"-")}
    Required / available space${s(w())} / ${s(t?.availableBytes)}
    `}function x(){f.querySelector(".import-wizard-steps").innerHTML=t.map((t,e)=>`${o(c(`importWizard.${t}`,t))}`).join(""),[z,g,y,$,W][p.step](),f.querySelector("[data-wizard-previous]").hidden=0===p.step,f.querySelector("[data-wizard-next]").hidden=0===p.step||4===p.step,f.querySelectorAll("button,input,select,textarea").forEach(t=>{t.matches("[data-wizard-close]")||(t.disabled=p.busy)}),v.textContent=p.error||p.status,v.classList.toggle("error",Boolean(p.error)),d.i18n?.apply?.(f),u.textContent=c("importWizard.open","Managed import")}function I(){f.hidden=!0,d.document.body.classList.remove("chimera-modal-open"),m?.focus?.()}async function k(){if(!p.selection.length)throw new Error(c("importWizard.error.selection","Select at least one non-empty file."));p.session||(p.session=await l.importUploadCreate(p.selection.map(t=>({relativePath:t.relativePath,size:t.size}))));for(const t of p.selection){const e=p.session.files.find(e=>e.relativePath===t.relativePath);if(!e||Number(e.size)!==t.size)throw new Error("Selected files do not match the resumable upload.");let a=Number(e.offset??e.bytesReceived??0);for(;at.componentId)||[],p.patchIds=new Set(p.classification.components.filter(t=>["patch_bundle","apr_ampr"].includes(t.type?.value)).map(t=>t.id)),p.artworkIds=new Set(p.classification.components.filter(t=>"image"===t.type?.value).map(t=>t.id)),x()}async function N(){p.preview=await l.importManifestPreview(p.job.id,h()),x()}async function j(){const t=r(p);if(t)throw new Error(c(t,t));await l.importManifestCreate(p.job.id,h()),await l.importJobValidate(p.job.id);const e=await l.importJobFinalize(p.job.id,{managedRootId:p.destinationId,relativePath:p.preview.destination});p.status=c("importWizard.done","Import finalized. No package was installed or executed."),p.job=e,p.confirmed=!1,x()}async function M(t){p.busy=!0,p.error="",x();try{await t()}catch(t){p.error=t.message}finally{p.busy=!1,x()}}function E(t,e){const a=p.order.indexOf(t),i=a+e;a<0||i<0||i>=p.order.length||([p.order[a],p.order[i]]=[p.order[i],p.order[a]],x())}u.addEventListener("click",function(){!function(){if(d.document.querySelector("link[data-import-wizard-style]"))return;const t=d.document.createElement("link");t.rel="stylesheet",t.href="styles/import-wizard.css",t.dataset.importWizardStyle="true",d.document.head.appendChild(t)}(),m=d.document.activeElement,f.hidden=!1,d.document.body.classList.add("chimera-modal-open"),p.error="",async function(){if(!l?.importUploadList)return p.error=c("importWizard.unavailable","Managed import is available from the authenticated Chimera server interface."),void x();try{[p.uploads,p.jobs,p.destinations]=await Promise.all([l.importUploadList(),l.importJobList(),l.importDestinations()])}catch(t){p.error=t.message}x()}().finally(()=>f.querySelector("[data-wizard-close]")?.focus())}),f.addEventListener("click",t=>{t.target===f||t.target.closest("[data-wizard-close]")?I():t.target.closest("[data-choose-files]")?f.querySelector("#import-wizard-files").click():t.target.closest("[data-choose-folder]")?f.querySelector("#import-wizard-folder").click():t.target.closest("[data-start-upload]")?M(k):t.target.closest("[data-run-inspection]")?M(S):t.target.closest("[data-refresh-preview]")?M(N):t.target.closest("[data-finalize]")?M(j):t.target.closest("[data-order-up]")?E(t.target.closest("[data-order-up]").dataset.orderUp,-1):t.target.closest("[data-order-down]")?E(t.target.closest("[data-order-down]").dataset.orderDown,1):t.target.closest("[data-resume-upload]")?(p.session=p.uploads.find(e=>e.id===t.target.closest("[data-resume-upload]").dataset.resumeUpload),p.selection=[],p.status=c("importWizard.resumeHint","Reselect the same files to continue."),x()):t.target.closest("[data-resume-job]")?M(async()=>{p.job=await l.importJobGet(t.target.closest("[data-resume-job]").dataset.resumeJob);try{p.classification=await l.importClassificationGet(p.job.id)}catch(t){p.classification=null}p.step="classified"===p.classification?.status?2:1,p.classification&&(p.order=p.classification.recipeProposal?.installOrder?.map(t=>t.componentId)||[])}):t.target.closest("[data-wizard-previous]")?(p.step=Math.max(0,p.step-1),p.error="",x()):t.target.closest("[data-wizard-next]")&&M(async()=>{const t=r(p);if(t)throw new Error(c(t,t));1!==p.step||p.classification||await S(),2!==p.step&&3!==p.step||await N(),p.step=Math.min(4,p.step+1)})}),f.addEventListener("change",t=>{if(t.target.matches("#import-wizard-files,#import-wizard-folder")&&(p.selection=e(t.target.files),p.session=p.session&&p.selection.length?p.session:null,p.error="",x()),t.target.matches("[data-attach-id]")){const e="artwork"===t.target.dataset.attachKind?p.artworkIds:p.patchIds;t.target.checked?e.add(t.target.dataset.attachId):e.delete(t.target.dataset.attachId)}t.target.matches("[data-destination]")&&(p.destinationId=t.target.value,p.preview=null,p.confirmed=!1,x()),t.target.matches("[data-final-confirm]")&&(p.confirmed=t.target.checked)}),f.addEventListener("input",t=>{t.target.matches("[data-install-notes]")&&(p.installNotes=t.target.value)}),f.addEventListener("keydown",t=>{if("Escape"===t.key)return t.preventDefault(),void I();if("Tab"!==t.key)return;const e=[...f.querySelectorAll("button:not([disabled]):not([hidden]),input:not([disabled]):not([hidden]),select:not([disabled]),textarea:not([disabled])")].filter(t=>null!==t.offsetParent),i=e.indexOf(d.document.activeElement),r=a(i,e.length,t.shiftKey);(t.shiftKey&&i<=0||!t.shiftKey&&i===e.length-1)&&(t.preventDefault(),e[r]?.focus())}),d.addEventListener("chimera:language-changed",()=>{x()}),x()},nextFocusIndex:a,normalizeFileSelection:e,validateWizardStep:r})}); \ No newline at end of file diff --git a/renderer/modules/init.js b/renderer/modules/init.js new file mode 100644 index 0000000..b523de2 --- /dev/null +++ b/renderer/modules/init.js @@ -0,0 +1,114 @@ +// ── External links — open in default browser via shell ──────────────────────── +document.addEventListener('click', e => { + const link = e.target.closest('a.external-link[data-url]'); + if (link) { e.preventDefault(); window.pork.openShell(link.dataset.url); } +}); + +document.addEventListener('click', e => { + const button = e.target.closest('[data-payload-target]'); + if (!button) return; + const target = document.getElementById(button.dataset.payloadTarget); + if (!target) return; + target.scrollIntoView({ + behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth', + block: 'start', + }); + target.classList.add('payload-workspace-highlight'); + window.setTimeout(() => target.classList.remove('payload-workspace-highlight'), 1200); +}); + +// Pin buttons +document.addEventListener('click', async e => { + const btn = e.target.closest('.btn-pin'); + if (!btn) return; + e.stopPropagation(); // prevent tile nav from firing if btn is inside a tile + const pinId = btn.dataset.pinId; + if (!pinId) return; + const s = await window.pork.getSettings(); + const pinned = new Set(s.pinnedCards || []); + if (pinned.has(pinId)) pinned.delete(pinId); + else pinned.add(pinId); + await window.pork.setSettings({ pinnedCards: [...pinned] }); + // Update pin button visuals everywhere + document.querySelectorAll(`.btn-pin[data-pin-id="${pinId}"]`).forEach(b => + b.classList.toggle('pinned', pinned.has(pinId)) + ); + // If on dashboard, refresh pinned grid immediately + if (document.querySelector('#page-dashboard.active')) renderPinnedTiles(); +}); + +// Dashboard tiles +document.addEventListener('click', e => { + const tile = e.target.closest('.dash-pinned-tile[data-nav-page]'); + if (tile) navigate(tile.dataset.navPage); +}); + +// Title bar +$('btn-min').addEventListener('click', () => window.pork.minimize()); +$('btn-max').addEventListener('click', () => window.pork.maximize()); +$('btn-cls').addEventListener('click', () => window.pork.close()); +$('sidebar-donate').addEventListener('click', event => { + event.preventDefault(); + if (typeof window.chimeraOpenAbout === 'function') window.chimeraOpenAbout(); + else window.pork.openDonate(); +}); +$('titlebar-x-link').addEventListener('click', e => { + e.preventDefault(); + showToast('Chimera · private PS4/PS5 operations hub', 'info'); +}); + +// Conversion jobs +window.pork.on('conv:job-update', (job) => { + if (document.querySelector('#page-games.active')) { + patchConvJobCard(job); + } +}); +window.pork.on('conv:queue:paused-change', () => { + if (document.querySelector('#page-games.active')) { + refreshConvQueuePanel().catch(() => {}); + } +}); + +// Init +(async function init() { + // Load just the startup essentials. Heavy page-specific setup is deferred + // until the user actually opens those pages. + const s = await window.pork.getSettings().catch(() => ({})); + applyDensity(s.interfaceDensity); + try { + await window.i18n.load(s.language || 'en'); + } finally { + window.chimeraResolveI18nReady?.(); + delete window.chimeraResolveI18nReady; + } + await loadDashboard(); + + // Restore FTP connection state immediately so the UI isn't blank while devices enumerate. + let connected = false; + try { + ({ connected } = await window.pork.ftpStatus()); + setConnected(connected); + $('sb-sync').textContent = `Last connection check · ${new Date().toLocaleTimeString()}`; + } catch (_) {} + + if (!connected && s.autoConnect && s.ftpHost) { + setStatus('Auto-connecting…'); + try { + const autoPorts = (s.ftpPorts && s.ftpPorts.length) ? s.ftpPorts : ['1337', '2121', '21']; + await window.pork.ftpConnect({ + host: s.ftpHost, + ports: autoPorts, + user: s.ftpUser || '', + password: s.ftpPass || '', + }); + setConnected(true); + setStatus('Connected', 'ok'); + $('sb-sync').textContent = `Last connection check · ${new Date().toLocaleTimeString()}`; + } catch (_) { + setConnected(false); + setStatus('Auto-connect failed — enter credentials and connect manually', 'error'); + } + } + + scheduleBackgroundWarm(1200); +})(); diff --git a/renderer/modules/install-destination.js b/renderer/modules/install-destination.js new file mode 100644 index 0000000..d06d1a0 --- /dev/null +++ b/renderer/modules/install-destination.js @@ -0,0 +1,77 @@ +(function installDestinationSelector() { + 'use strict'; + + let options = []; + let requiredBytes = 0; + + const esc = value => String(value == null ? '' : value).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + const bytes = value => value == null ? 'unknown' : value >= 1024 ** 3 ? `${(value / 1024 ** 3).toFixed(1)} GB` : value >= 1024 ** 2 ? `${(value / 1024 ** 2).toFixed(1)} MB` : `${Math.max(0, value)} B`; + const age = value => value == null ? 'unknown age' : value < 60_000 ? 'just now' : value < 3_600_000 ? `${Math.ceil(value / 60_000)} min old` : `${Math.ceil(value / 3_600_000)} h old`; + const reason = code => ({ + 'offline-volume': 'offline', + 'install-capability-disabled': 'install disabled by adapter', + 'missing-mountpoint': 'mountpoint unavailable', + 'capacity-unknown': 'capacity unknown', + 'insufficient-capacity': 'not enough free space', + 'stale-capacity-telemetry': 'capacity telemetry stale', + 'stale-capability-telemetry': 'capability telemetry stale', + 'compatibility-incompatible': 'blocked by applicable compatibility evidence', + }[code] || code); + + function selected() { + const select = document.getElementById('install-destination-volume'); + return options.find(item => item.volumeId === select?.value) || null; + } + + function expertOpen() { + return document.getElementById('install-expert-destination')?.open === true; + } + + function renderStatus() { + const status = document.getElementById('install-destination-status'); + const option = selected(); + if (!status) return; + if (option) { + status.className = `install-destination-status ${option.eligible ? 'is-eligible' : 'is-ineligible'}`; + status.innerHTML = `${esc(option.eligible ? 'Eligible destination' : 'Destination unavailable')}Required ${esc(bytes(requiredBytes))} · free ${esc(bytes(option.freeBytes))} · projected ${esc(bytes(option.projectedFreeBytes))} · evidence ${esc(age(option.capacityAgeMs))}${option.reasons.length ? ` · ${esc(option.reasons.map(reason).join(', '))}` : ''}`; + const raw = document.getElementById('install-ftp-path'); + if (option.compatibility) status.insertAdjacentHTML('beforeend', window.chimeraCompatibilityGuidance?.markup?.(option.compatibility) || ''); + if (raw && option.suggestedRemotePath) raw.value = option.suggestedRemotePath; + } else { + status.className = 'install-destination-status is-ineligible'; + status.innerHTML = 'No eligible semantic destinationRefresh console storage evidence or use the Expert raw-path override.'; + } + if (typeof updateInstallConfirmState === 'function') updateInstallConfirmState('pfs'); + } + + async function load({ titleId, required = 0, legacyPath = '' } = {}) { + requiredBytes = Math.max(0, Number(required) || 0); + const packet = await window.pork.installDestinations({ titleId, requiredBytes }); + options = Array.isArray(packet?.destinations) ? packet.destinations : []; + const select = document.getElementById('install-destination-volume'); + const raw = document.getElementById('install-ftp-path'); + if (raw) raw.value = legacyPath || ''; + if (!select) return packet; + select.innerHTML = '' + options.map(option => { + const suffix = option.eligible ? `${bytes(option.freeBytes)} free` : option.reasons.map(reason).join(', '); + return ``; + }).join(''); + const preferred = options.find(option => option.eligible); + select.value = preferred?.volumeId || ''; + renderStatus(); + return packet; + } + + function selection() { + const option = selected(); + if (option?.eligible) return { destinationVolumeId: option.volumeId, remotePath: option.suggestedRemotePath, expertRawPath: false }; + const raw = document.getElementById('install-ftp-path')?.value?.trim() || ''; + return expertOpen() && raw ? { destinationVolumeId: null, remotePath: raw, expertRawPath: true } : null; + } + + document.getElementById('install-destination-volume')?.addEventListener('change', renderStatus); + document.getElementById('install-expert-destination')?.addEventListener('toggle', renderStatus); + document.getElementById('install-ftp-path')?.addEventListener('input', renderStatus); + + window.chimeraInstallDestination = Object.freeze({ load, selection, isReady: () => Boolean(selection()) }); +})(); diff --git a/renderer/modules/library-model.js b/renderer/modules/library-model.js new file mode 100644 index 0000000..b458d23 --- /dev/null +++ b/renderer/modules/library-model.js @@ -0,0 +1,16 @@ +'use strict'; + +(function exposeLibraryModel(root, factory) { + const api = factory(); + if (typeof module === 'object' && module.exports) module.exports = api; + else root.chimeraLibraryModel = api; +})(typeof globalThis !== 'undefined' ? globalThis : window, function createLibraryModel() { + function items(response) { + if (Array.isArray(response)) return response; + return Array.isArray(response?.items) ? response.items : []; + } + function isInstalled(title) { return Number(title?.availability?.confirmedInstallCount || 0) > 0; } + function hasLocalCopy(title) { return Number(title?.protection?.localCopyCount || title?.availability?.localCopyCount || 0) > 0; } + function needsProtection(title) { return isInstalled(title) && !hasLocalCopy(title); } + return Object.freeze({ items, isInstalled, hasLocalCopy, needsProtection }); +}); diff --git a/renderer/modules/manage.js b/renderer/modules/manage.js new file mode 100644 index 0000000..8544dc4 --- /dev/null +++ b/renderer/modules/manage.js @@ -0,0 +1,391 @@ +// ── Manage panel ────────────────────────────────────────────────────────────── +let uploadInProgress = false; +let uploadLocalPath = null; + +window.pork.on('ftp:upload:progress', info => { + const log = $('manage-upload-log'); + if (!log) return; + // Directory upload shape has filesTotal; single-file shape has bytes/size + if (info.filesTotal != null) { + const pct = info.filesTotal > 0 ? Math.round((info.filesDone / info.filesTotal) * 100) : 0; + log.textContent = `Uploading… File ${info.filesDone}/${info.filesTotal} (${pct}%)${info.file ? ' ' + info.file : ''}`; + } else { + const done = info.bytes || 0; + const total = info.size || 0; + const pct = total > 0 ? Math.round((done / total) * 100) : 0; + log.textContent = `Uploading… ${pct}% (${fmt(done)} / ${fmt(total)})`; + } +}); + +async function renderManagePanel(g, backups) { + // ── Backup file list (local only — backpork entries shown in Backpork Versions) ─ + const list = $('manage-backup-list'); + backups = backups.filter(b => b.source !== 'backpork'); + $('mtab-manage-count').textContent = backups.length || ''; + + if (!backups.length) { + list.innerHTML = '
    No backup files recorded.
    '; + } else { + list.innerHTML = backups.map(b => { + const filename = b.backup_path.replace(/\\/g, '/').split('/').pop(); + return ` +
    +
    + ${escHtml(filename)} + ${fmt(b.size)} + ${escHtml(b.backup_type || 'dump')} +
    +
    + + + +
    +
    `; + }).join(''); + } + + // ── Upload section state ──────────────────────────────────────────────────── + uploadLocalPath = null; + $('manage-remote-path').value = ''; + $('manage-remote-path').readOnly = true; + $('btn-manage-upload').disabled = true; + $('manage-upload-log').hidden = true; + + // ── Backed-up / Installed status buttons ───────────────────────────────────── + const toggleBackedBtn = $('btn-manage-toggle-backed'); + if (g.backed_up) { + toggleBackedBtn.textContent = 'Clear Backed Up Status'; + toggleBackedBtn.className = 'btn btn-danger'; + } else { + toggleBackedBtn.textContent = 'Mark as Backed Up'; + toggleBackedBtn.className = 'btn btn-teal'; + } + const toggleInstBtn = $('btn-manage-toggle-installed'); + if (g.installed) { + toggleInstBtn.textContent = 'Mark as Not Installed'; + toggleInstBtn.className = 'btn btn-danger'; + } else { + toggleInstBtn.textContent = 'Mark as Installed'; + toggleInstBtn.className = 'btn'; + } + + // ── Install location display ────────────────────────────────────────────────── + renderManageInstallLocation(g); + + // ── Backpork versions section ──────────────────────────────────────────────── + await renderBackporkSection(g); +} + +// ── Manage: install location display ───────────────────────────────────────── + +function renderManageInstallLocation(g) { + const locRow = $('manage-install-location'); + const noLocRow = $('manage-no-location'); + const locPath = $('manage-loc-path'); + const deleteBtn = $('btn-manage-delete-ps5'); + const backupBtn = $('btn-manage-backup-ps5'); + + if (g?.ftp_path) { + locRow.hidden = false; + noLocRow.hidden = true; + locPath.textContent = g.ftp_path; + deleteBtn.disabled = !state.connected; + backupBtn.disabled = !state.connected; + } else { + locRow.hidden = true; + noLocRow.hidden = false; + locPath.textContent = ''; + deleteBtn.disabled = true; + backupBtn.disabled = true; + } +} + +$('btn-manage-forget-loc').addEventListener('click', async () => { + if (!state.currentGame) return; + const gameId = state.currentGame.game_id; + await window.pork.updateGame({ game_id: gameId, fields: { ftp_path: '' } }); + state.currentGame = { ...state.currentGame, ftp_path: '' }; + renderManageInstallLocation(state.currentGame); + setStatus('PS5 install location forgotten', 'ok'); +}); + +$('btn-manage-backup-ps5').addEventListener('click', async () => { + if (!state.currentGame || !state.connected) return; + const { game_id, ftp_path } = state.currentGame; + if (!ftp_path) { setStatus('No PS5 location stored — run a scan first', 'error'); return; } + + const btn = $('btn-manage-backup-ps5'); + btn.disabled = true; + try { + await window.pork.ftpDownloadFolder(game_id); + setStatus('Backup queued — see Transfers for progress', 'ok'); + } catch (e) { + setStatus(e.message, 'error'); + } finally { + btn.disabled = !state.connected || !state.currentGame?.ftp_path; + } +}); + +$('btn-manage-delete-ps5').addEventListener('click', async () => { + if (!state.currentGame) return; + const g = state.currentGame; + if (!g.ftp_path) { setStatus('No PS5 location stored — cannot delete', 'error'); return; } + + const confirmation = await showExactConfirmation( + `Delete "${g.title || g.game_id}" from PS5?\n\nFolder: ${g.ftp_path}\n\nThis cannot be undone.`, + 'VERWIJDER VAN PS5' + ); + if (!confirmation) return; + + const btn = $('btn-manage-delete-ps5'); + btn.disabled = true; + btn.textContent = 'Deleting…'; + + try { + await window.pork.ftpDeleteGame(g.game_id, confirmation); + state.currentGame = { ...g, availability: { ...(g.availability || {}), state: 'absent', confirmedInstallCount: 0 }, ftp_path: '' }; + state.games = await window.pork.listGames({}); + renderManageInstallLocation(state.currentGame); + const toggleInstBtn = $('btn-manage-toggle-installed'); + toggleInstBtn.textContent = 'Mark as Installed'; + toggleInstBtn.className = 'btn'; + setStatus(`${g.title || g.game_id} deleted from PS5`, 'ok'); + } catch (err) { + setStatus(`Delete failed: ${err.message}`, 'error'); + } finally { + btn.textContent = 'Delete from PS5'; + btn.disabled = !state.connected || !state.currentGame?.ftp_path; + } +}); + +// ── Backpork section in Manage tab ──────────────────────────────────────────── +let porkInProgress = false; + +window.pork.on('backporks:pork:progress', ({ file, filesDone, filesTotal, bytesOverall }) => { + const fill = $('pork-progress-fill'); + const label = $('pork-progress-label'); + const pct = filesTotal > 0 ? Math.round((filesDone / filesTotal) * 100) : 0; + if (fill) fill.style.width = `${pct}%`; + if (label) label.textContent = `Uploading ${filesDone + 1}/${filesTotal}: ${escHtml(file || '')}`; +}); + +async function renderBackporkSection(g) { + const section = $('manage-backpork-section'); + const entries = await window.pork.backporksEntries(g.game_id); + + if (!entries.length) { section.hidden = true; return; } + section.hidden = false; + + // Porked-to status + const statusEl = $('manage-porked-status'); + if (g.porked_firmware) { + const when = g.porked_at ? ` · ${new Date(g.porked_at).toLocaleDateString()}` : ''; + statusEl.innerHTML = ` +
    + ✓ Porked to firmware ${escHtml(g.porked_firmware)}${escHtml(when)} + +
    `; + } else { + statusEl.innerHTML = ''; + } + + // Entry list + const listEl = $('manage-backpork-list'); + listEl.innerHTML = entries.map(e => { + const gamePath = e.game_path || e.folder_path; + const displayPath = gamePath.replace(/\\/g, '/').split('/').pop(); + return ` +
    + ${escHtml(e.folder_name)} + ${escHtml(displayPath)} + + +
    `; + }).join(''); + + // Reset progress bar + $('manage-pork-progress').hidden = true; + $('pork-progress-fill').style.width = '0%'; +} + +// Clear porked status +$('manage-porked-status').addEventListener('click', async e => { + const btn = e.target.closest('.clear-pork'); + if (!btn) return; + const gameId = btn.dataset.game; + await window.pork.updateGame({ game_id: gameId, fields: { porked_firmware: null, porked_at: null } }); + state.games = await window.pork.listGames({}); + const updated = state.games.find(x => x.game_id === gameId) || state.currentGame; + state.currentGame = { ...state.currentGame, porked_firmware: null, porked_at: null }; + await renderBackporkSection(state.currentGame); + setStatus('Porked status cleared', 'ok'); +}); + +// Backpork list click delegation +$('manage-backpork-list').addEventListener('click', async e => { + // ── View Backpork folder ───────────────────────────────────────────────────── + const viewBtn = e.target.closest('.manage-btn-view-backpork'); + if (viewBtn) { + window.pork.openShell(viewBtn.dataset.open); + return; + } + + // Pork to PS5 + const btn = e.target.closest('.pork-btn'); + if (!btn || porkInProgress) return; + const entry = btn.closest('.manage-backpork-entry'); + if (!entry) return; + + const gameId = entry.dataset.game; + const folderName = entry.dataset.fw; + const folderPath = entry.dataset.path; + + // Ensure a destination is remembered before porking + const ftpDest = await ensureGameFtpPath(gameId); + if (!ftpDest) return; // user cancelled picker + + porkInProgress = true; + btn.disabled = true; + $('manage-pork-progress').hidden = false; + $('pork-progress-fill').style.width = '0%'; + $('pork-progress-label').textContent = 'Starting…'; + setStatus(`Porking ${gameId} (${folderName})…`); + + try { + const { remotePath } = await window.pork.backporksPork(gameId, folderName, folderPath); + $('manage-pork-progress').hidden = true; + setStatus(`Porked to PS5 at ${remotePath}`, 'ok'); + + // Refresh game state + state.games = await window.pork.listGames({}); + const updated = state.games.find(x => x.game_id === gameId); + if (updated) { + state.currentGame = updated; + await renderBackporkSection(updated); + // Update installed toggle + const toggleInstBtn = $('btn-manage-toggle-installed'); + toggleInstBtn.textContent = 'Mark as Not Installed'; + toggleInstBtn.className = 'btn btn-danger'; + } + } catch (err) { + $('manage-pork-progress').hidden = true; + setStatus(`Pork failed: ${err.message}`, 'error'); + } finally { + porkInProgress = false; + btn.disabled = false; + } +}); + +// Toggle installed status +$('btn-manage-toggle-installed').addEventListener('click', async () => { + setStatus('Install status is derived from complete console scan evidence.', 'info'); +}); + +// ── Delegation for all manage-panel interactions (attached once, never re-bound) +$('manage-backup-list').addEventListener('click', async e => { + const item = e.target.closest('.manage-backup-item'); + if (!item) return; + + const backupId = Number(item.dataset.backupId); + const gameId = item.dataset.game; + const local = item.dataset.local; + const folder = item.dataset.folder; + + // ── Open Folder ───────────────────────────────────────────────────────────── + if (e.target.closest('.manage-btn-folder')) { + window.pork.openShell(folder); + return; + } + + // ── Upload to PS5 — inline expand ─────────────────────────────────────────── + if (e.target.closest('.manage-btn-upload')) { + if (uploadInProgress) return; + await openInstallModal(gameId, 'pfs'); + return; + } + + // ── Remove backup record ───────────────────────────────────────────────────── + if (e.target.closest('.manage-btn-remove')) { + const btn = e.target.closest('.manage-btn-remove'); + const confirmation = await showExactConfirmation('Remove this backup record from Chimera?', 'VERWIJDER'); + if (!confirmation) return; + btn.disabled = true; + try { + await window.pork.deleteBackup(backupId, confirmation); + setStatus('Backup record removed', 'ok'); + + // Refresh the modal's backup-dependent sections + const backups = await window.pork.listBackups(gameId); + const localBackups = backups.filter(b => b.source !== 'backpork'); + state.games = await window.pork.listGames({}); + const g = state.games.find(x => x.game_id === gameId) || state.currentGame; + state.currentGame = g; + + await renderManagePanel(g, backups); + await renderHashSection(gameId, backups); + $('modal-backup-list').innerHTML = localBackups.length + ? localBackups.map(b => `
  • ${escHtml(b.backup_path)}${fmt(b.size)}
  • `).join('') + : '
  • No backups recorded
  • '; + } catch (err) { + setStatus(err.message, 'error'); + btn.disabled = false; + } + return; + } +}); + +$('btn-manage-upload').addEventListener('click', async () => { + if (uploadInProgress || !uploadLocalPath) return; + const remotePath = $('manage-remote-path').value.trim(); + if (!remotePath) { setStatus('Enter a remote path on the PS5', 'error'); return; } + + uploadInProgress = true; + $('btn-manage-upload').disabled = true; + const log = $('manage-upload-log'); + log.hidden = false; + log.textContent = 'Starting upload…'; + + try { + await window.pork.ftpUpload({ localPath: uploadLocalPath, remotePath }); + log.textContent = 'Upload complete ✓'; + setStatus('Uploaded to PS5 successfully', 'ok'); + uploadLocalPath = null; + $('manage-remote-path').value = ''; + $('manage-remote-path').readOnly = true; + document.querySelectorAll('.manage-item-selected').forEach(el => el.classList.remove('manage-item-selected')); + } catch (e) { + log.textContent = `Upload failed: ${e.message}`; + setStatus(e.message, 'error'); + $('btn-manage-upload').disabled = false; + } finally { + uploadInProgress = false; + } +}); + +$('btn-manage-toggle-backed').addEventListener('click', async () => { + setStatus('Protection status is derived from registered local-copy evidence.', 'info'); +}); + +$('btn-manage-refresh-meta').addEventListener('click', async () => { + if (!state.currentGame) return; + const { game_id } = state.currentGame; + $('btn-manage-refresh-meta').disabled = true; + setStatus('Refreshing metadata…'); + try { + await window.pork.prosperoFetch(game_id); + setStatus('Metadata refreshed', 'ok'); + } catch (e) { + setStatus(`Refresh failed: ${e.message}`, 'error'); + } finally { + $('btn-manage-refresh-meta').disabled = false; + } +}); + diff --git a/renderer/modules/media.js b/renderer/modules/media.js new file mode 100644 index 0000000..6494eb2 --- /dev/null +++ b/renderer/modules/media.js @@ -0,0 +1,820 @@ +// ── Transfer Manager controls ───────────────────────────────────────────────── + +// ── Media page ──────────────────────────────────────────────────────────────── + +// Format a Unix ms timestamp into a short human-readable string. +// Respects _timeFormat ('12h' | '24h'). +let _timeFormat = '12h'; // default; overwritten by loadSettings +function _fmtDate(ms) { + if (!ms) return ''; + const d = new Date(ms); + const now = new Date(); + let time; + if (_timeFormat === '24h') { + const pad = n => String(n).padStart(2, '0'); + time = `${pad(d.getHours())}:${pad(d.getMinutes())}`; + } else { + const h = d.getHours(); + const m = d.getMinutes(); + const ampm = h >= 12 ? 'PM' : 'AM'; + const h12 = h % 12 || 12; + time = `${h12}:${String(m).padStart(2, '0')} ${ampm}`; + } + const isSameDay = d.toDateString() === now.toDateString(); + const yesterday = new Date(now); yesterday.setDate(now.getDate() - 1); + const isYesterday = d.toDateString() === yesterday.toDateString(); + if (isSameDay) return `Today ${time}`; + if (isYesterday) return `Yesterday ${time}`; + const mo = d.toLocaleString('default', { month: 'short' }); + return `${mo} ${d.getDate()} ${time}`; +} + +let _mediaItems = []; // all items returned by last scan +let _mediaFilter = 'all'; +let _mediaSort = 'date-desc'; +let _mediaSearch = ''; +let _mediaSearchTimer = null; +const _thumbCache = new Map(); // remotePath → dataUrl (in-memory, cleared on new scan) +const _thumbFetching = new Set(); // remotePaths currently being fetched +let _thumbQueued = 0; // total thumb loads enqueued in current render cycle +let _thumbDone = 0; // completed (success + failure) +let _mediaCacheStatus = { entries: [], metrics: {} }; + +function _mediaText(key, fallback) { + return window.t ? window.t(key, fallback) : fallback; +} + +function _renderMediaCacheStatus() { + const metrics = _mediaCacheStatus.metrics || {}; + const target = $('media-cache-metrics'); + if (!target) return; + target.textContent = metrics.managedCount + ? `${metrics.managedCount} ${_mediaText('media.cache.assets', 'assets')} · ${fmt(metrics.managedBytes || 0)} · ${metrics.pinnedCount || 0} ${_mediaText('media.cache.pinned', 'pinned')}` + : _mediaText('media.cache.empty', 'No managed cache assets.'); +} + +async function _refreshMediaCacheStatus({ rerender = false } = {}) { + if (!window.pork.mediaCacheStatus) return; + _mediaCacheStatus = await window.pork.mediaCacheStatus(); + _renderMediaCacheStatus(); + if (rerender) renderMediaGrid(); +} + +async function _mediaSha256(blob) { + const digest = await crypto.subtle.digest('SHA-256', await blob.arrayBuffer()); + return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join(''); +} + +function _renderImportedMedia(packet) { + const target = $('media-imported-list'); + if (!target) return; + const items = packet?.items || []; + target.innerHTML = items.map(item => `
    + ${item.mime.startsWith('image/') ? `` : ``} +
    ${escHtml(item.titleId)} · ${escHtml(item.kind)}${escHtml(item.originalName)} · ${fmt(item.bytes)} · ${escHtml(item.provenance.provider)}${item.kind === 'cover' ? `` : ''}
    +
    `).join(''); +} + +async function _loadImportedMedia() { + if (!window.pork.mediaImportsList) return; + _renderImportedMedia(await window.pork.mediaImportsList()); +} + +function _updateThumbStatus() { + const el = $('media-thumb-status'); + if (!el) return; + const pending = _thumbQueued - _thumbDone; + if (pending <= 0 || _thumbQueued === 0) { el.hidden = true; return; } + el.hidden = false; + el.textContent = `Loading thumbnails: ${_thumbDone} / ${_thumbQueued}`; +} + +/** Show or update the thumb-status bar with an arbitrary message (for clip-batch). */ +function _setThumbStatusMsg(msg) { + const el = $('media-thumb-status'); + if (!el) return; + if (msg == null) { el.hidden = true; return; } + el.textContent = msg; + el.hidden = false; +} + +function _setCardLabel(remotePath, text) { + const lbl = document.querySelector(`.thumb-label[data-remote="${CSS.escape(remotePath)}"]`); + if (lbl) lbl.textContent = text; +} + +// ── Video frame extractor removed ─────────────────────────────────────────── +// Webm thumbnails are not generated — the card shows a WEBM type badge instead. +// generateVideoThumb intentionally omitted. + +// ── IntersectionObserver for lazy thumbnail loading ─────────────────────────── +const _thumbObserver = new IntersectionObserver(entries => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + const imgEl = entry.target; + const remote = imgEl.dataset.remote; + if (!remote) continue; + _thumbObserver.unobserve(imgEl); + + if (_thumbCache.has(remote)) { + _applyThumb(imgEl, _thumbCache.get(remote)); + continue; + } + if (_thumbFetching.has(remote)) continue; + _thumbFetching.add(remote); + _thumbQueued++; + _updateThumbStatus(); + + // Videos: screenshots only now (clips use static badge) + const thumbPromise = window.pork.mediaFetchThumb(remote); + + thumbPromise + .then(thumbUrl => { + _thumbCache.set(remote, thumbUrl); + document.querySelectorAll(`.media-thumb-img[data-remote="${CSS.escape(remote)}"]`).forEach(el => { + _applyThumb(el, thumbUrl); + }); + }) + .catch(err => { + const card = imgEl.closest('.media-card'); + const wrap = imgEl.closest('.media-card-thumb'); + wrap?.classList.add('thumb-error'); + // Show a retry button so the user isn't left with a silent `!` + if (card && !card.querySelector('.btn-thumb-retry')) { + const retryBtn = document.createElement('button'); + retryBtn.className = 'btn btn-sm btn-thumb-retry'; + retryBtn.title = err?.message || 'Thumbnail failed'; + retryBtn.textContent = '\u21BB Retry'; + retryBtn.addEventListener('click', () => { + _thumbFetching.delete(remote); + _thumbCache.delete(remote); + _thumbQueued = Math.max(0, _thumbQueued - 1); + _thumbDone = Math.max(0, _thumbDone - 1); + retryBtn.remove(); + wrap?.classList.remove('thumb-error'); + // Re-add the spinner and img, then re-observe + const newSpin = document.createElement('div'); + newSpin.className = 'thumb-spin'; + const newLbl = document.createElement('div'); + newLbl.className = 'thumb-label'; newLbl.dataset.remote = remote; + newLbl.textContent = 'Waiting…'; + wrap?.prepend(newLbl); + wrap?.prepend(newSpin); + imgEl.src = ''; imgEl.classList.remove('thumb-loaded'); imgEl.classList.add('thumb-loading'); + _thumbObserver.observe(imgEl); + }); + card.querySelector('.media-card-body')?.prepend(retryBtn); + } + }) + .finally(() => { + _thumbFetching.delete(remote); + _thumbDone++; + _updateThumbStatus(); + }); + } +}, { rootMargin: '200px', threshold: 0 }); + +function _applyThumb(imgEl, dataUrl) { + imgEl.src = dataUrl; + imgEl.classList.remove('thumb-loading'); + imgEl.classList.add('thumb-loaded'); + const spin = imgEl.parentElement?.querySelector('.thumb-spin'); + if (spin) spin.remove(); +} + +async function loadMedia() { + const gameContext = consumeGameWorkspaceContext('media'); + if (gameContext) { + _mediaSearch = gameContext; + if ($('media-search')) $('media-search').value = gameContext; + } + try { + const s = await window.pork.getSettings(); + if (s.mediaLocalPath) $('media-save-path').value = s.mediaLocalPath; + } catch (_) {} + if (window.pork.mediaCatalog) { + try { + const catalog = await window.pork.mediaCatalog(); + if (Array.isArray(catalog?.items)) { + _mediaItems = catalog.items; + const status = $('media-scan-status'); + if (catalog.scannedAt) { + status.textContent = `Catalogus: ${catalog.stats?.total || catalog.items.length} items · ${_fmtDate(catalog.scannedAt)}${catalog.stale ? ' · nieuwe scan aanbevolen' : ''}`; + status.hidden = false; + } + } + } catch (_) {} + } + try { await _refreshMediaCacheStatus(); } catch (_) {} + try { await _loadImportedMedia(); } catch (_) {} + renderMediaGrid(); +} + +function getFilteredMedia() { + const filtered = _mediaItems.filter(item => { + if (_mediaFilter !== 'all' && item.type !== _mediaFilter) return false; + if (_mediaSearch) { + const q = _mediaSearch.toLowerCase(); + return item.game_id.toLowerCase().includes(q) || item.filename.toLowerCase().includes(q); + } + return true; + }); + // Sort + filtered.sort((a, b) => { + switch (_mediaSort) { + case 'date-asc': return (a.capturedAt || 0) - (b.capturedAt || 0); + case 'size-desc': return b.size - a.size; + case 'name-asc': return a.filename.localeCompare(b.filename); + default: return (b.capturedAt || 0) - (a.capturedAt || 0); // date-desc + } + }); + return filtered; +} + +function renderMediaGrid() { + const grid = $('media-grid'); + const empty = $('media-empty'); + const all = _mediaItems; + const shots = all.filter(i => i.type === 'screenshot'); + const clips = all.filter(i => i.type === 'clip'); + + $('media-count-all').textContent = all.length || ''; + $('media-count-screenshots').textContent = shots.length || ''; + $('media-count-clips').textContent = clips.length || ''; + + // Show the "Fetch Clip Thumbnails" button whenever there are clips loaded + const clipThumbBtn = $('btn-clip-thumbs'); + if (clipThumbBtn) { + const missing = clips.filter(c => !_thumbCache.has(c.remotePath)).length; + clipThumbBtn.style.display = clips.length ? '' : 'none'; + clipThumbBtn.title = missing + ? `Generate thumbnails for ${missing} clip${missing !== 1 ? 's' : ''} (${clips.length - missing} already cached)` + : 'All clip thumbnails are already cached'; + } + + const badge = $('nav-media-badge'); + // The Chimera v2 navigation replaces Porkfolio's legacy media badge. The + // background warmer may render Media after that replacement, so the badge is + // deliberately optional. + if (badge) { + if (all.length) { badge.textContent = all.length; badge.hidden = false; } + else badge.hidden = true; + } + + const visible = getFilteredMedia(); + grid.innerHTML = ''; + + if (!visible.length) { empty.hidden = false; return; } + empty.hidden = true; + + const iconMap = new Map((state.games || []).map(g => [g.game_id, g.prospero_icon_url || ''])); + + for (const item of visible) { + const card = document.createElement('div'); + card.className = 'media-card'; + card.dataset.remote = item.remotePath; + card.dataset.type = item.type; + + const icon = iconMap.get(item.game_id) || ''; + const iconHtml = icon + ? `` + : `
    `; + + // Thumbnail area — real for screenshots (lazy-loaded), styled placeholder for clips + let thumbHtml; + if (item.type === 'screenshot') { + const cached = _thumbCache.get(item.remotePath); + if (cached) { + thumbHtml = ` +
    + +
    `; + } else { + thumbHtml = ` +
    +
    + +
    `; + } + } else { + // Video clip — static WEBM badge + play overlay; no thumbnail download. + const cachedClip = _thumbCache.get(item.remotePath); + const clipImg = cachedClip + ? `` + : `
    WEBM
    `; + thumbHtml = ` +
    + ${clipImg} +
    + + + + +
    +
    `; + } + + const cachedAsset = item.cache || (_mediaCacheStatus.entries || []).find(entry => entry.origin?.locator === item.remotePath && entry.kind === 'cached'); + const provenance = item.provenance || { type: 'remote', provider: 'ps5-ftp' }; + const originLabel = provenance.type === 'remote' + ? _mediaText('media.cache.remote', 'Remote PS5') + : provenance.type === 'imported' ? _mediaText('media.cache.imported', 'Imported original') : _mediaText('media.cache.generated', 'Generated'); + card.innerHTML = ` + ${thumbHtml} +
    +
    + ${iconHtml} + ${escHtml(item.game_id)} +
    +
    ${escHtml(item.filename)}
    +
    + ${fmt(item.size)} + ${item.capturedAt ? `${_fmtDate(item.capturedAt)}` : ''} +
    +
    ${escHtml(originLabel)}${escHtml(provenance.provider || '')}${cachedAsset ? `` : ''}
    +
    + ${item.type === 'clip' + ? `` + : ''} + + +
    +
    `; + grid.appendChild(card); + + // Register lazy loading — screenshots only; clips show a static WEBM badge. + if (item.type === 'screenshot' && !_thumbCache.has(item.remotePath)) { + const imgEl = card.querySelector('.media-thumb-img'); + if (imgEl) _thumbObserver.observe(imgEl); + } + } +} + +$('media-grid').addEventListener('click', async event => { + const button = event.target.closest('[data-cache-pin]'); + if (!button || !window.pork.mediaCachePin) return; + button.disabled = true; + try { + await window.pork.mediaCachePin(button.dataset.cachePin, button.dataset.pinned !== 'true'); + await _refreshMediaCacheStatus({ rerender: true }); + } catch (error) { + setStatus(error.message, 'error'); + button.disabled = false; + } +}); + +async function _clearMediaCache(options) { + if (!window.pork.mediaCacheClear) return; + const result = await window.pork.mediaCacheClear(options); + _thumbCache.clear(); + await _refreshMediaCacheStatus({ rerender: true }); + setStatus(`${result.removed || 0} ${_mediaText('media.cache.cleared', 'cache assets cleared')}.`, 'ok'); +} + +$('btn-media-cache-expired').addEventListener('click', () => _clearMediaCache({ expiredOnly: true }).catch(error => setStatus(error.message, 'error'))); +$('btn-media-cache-unpinned').addEventListener('click', async () => { + const ok = await showConfirm(_mediaText('media.cache.clearConfirm', 'Clear all unpinned generated and cached media? Imported originals are never deleted.')); + if (ok) await _clearMediaCache({}); +}); + +$('btn-media-import').addEventListener('click', async () => { + const button = $('btn-media-import'); + const status = $('media-import-status'); + const file = $('media-import-file').files?.[0]; + const titleId = $('media-import-title-id').value.trim().toUpperCase(); + const kind = $('media-import-kind').value; + if (!file || !/^(CUSA|PPSA)\d{5}$/.test(titleId)) return setStatus(_mediaText('media.import.invalid', 'Select a file and valid title ID.'), 'error'); + button.disabled = true; + try { + const session = await window.pork.importUploadCreate([{ relativePath: file.name, size: file.size }]); + const staged = session.files[0]; + let offset = 0; + while (offset < file.size) { + const chunk = file.slice(offset, Math.min(file.size, offset + 4 * 1024 * 1024)); + const progress = await window.pork.importUploadChunk(session.id, staged.id, offset, chunk, await _mediaSha256(chunk)); + offset = Number(progress.files[0].offset); + status.textContent = `${Math.round(offset / file.size * 100)}%`; + } + const crop = $('media-import-crop').value === 'center' ? { x: 0.125, y: 0, width: 0.75, height: 1 } : { x: 0, y: 0, width: 1, height: 1 }; + await window.pork.mediaImportFinalize({ sessionId: session.id, fileId: staged.id, titleId, kind, crop: kind === 'cover' ? crop : null, preferred: kind === 'cover' && $('media-import-preferred').checked }); + status.textContent = _mediaText('media.import.done', 'Import complete.'); + $('media-import-file').value = ''; + await _loadImportedMedia(); + } catch (error) { status.textContent = error.message; setStatus(error.message, 'error'); } + finally { button.disabled = false; } +}); + +$('media-imported-list').addEventListener('click', async event => { + const button = event.target.closest('[data-preferred-cover]'); + if (!button || !window.pork.mediaImportPreferred) return; + await window.pork.mediaImportPreferred(button.dataset.titleId, button.dataset.preferredCover); + await _loadImportedMedia(); +}); + +// Clip thumbnail batch progress — update the status bar with live count +window.pork.on('media:clip-thumb:progress', ({ done, total, remote, state }) => { + const label = remote ? remote.split('/').pop() : ''; + if (state === 'downloading') { + _setThumbStatusMsg(`Fetching clip thumbnails: ${done} / ${total} — downloading ${label}…`); + } else if (state === 'extracting') { + _setThumbStatusMsg(`Fetching clip thumbnails: ${done} / ${total} — extracting frame…`); + } else { + // 'ok' or 'failed' + if (done >= total) { + const clips = _mediaItems.filter(i => i.type === 'clip'); + const missing = clips.filter(c => !_thumbCache.has(c.remotePath)).length; + _setThumbStatusMsg(`Done — fetched ${done} clip thumbnail${done !== 1 ? 's' : ''}${missing ? `, ${missing} still missing (partial download may not have contained a keyframe)` : ''}`); + setTimeout(() => _setThumbStatusMsg(null), 8000); + // Refresh button tooltip + const clipThumbBtn = $('btn-clip-thumbs'); + if (clipThumbBtn) { + clipThumbBtn.disabled = false; + clipThumbBtn.textContent = '\u{1F3AC} Fetch Clip Thumbnails'; + } + renderMediaGrid(); + } else { + _setThumbStatusMsg(`Fetching clip thumbnails: ${done} / ${total}${state === 'failed' ? ` (last failed)` : ''}`); + } + } +}); + +// ── Fetch Clip Thumbnails button ────────────────────────────────────────────── +$('btn-clip-thumbs').addEventListener('click', async () => { + const clips = _mediaItems.filter(i => i.type === 'clip'); + const missing = clips.filter(c => !_thumbCache.has(c.remotePath)); + + if (missing.length === 0) { + setStatus('All clip thumbnails are already cached.', 'ok'); + return; + } + + // Warn if it will take a while — each clip needs a partial FTP download + ffmpeg + const warnThreshold = 5; + if (missing.length > warnThreshold) { + const ok = await showConfirm( + `Fetching thumbnails for ${missing.length} video clip${missing.length !== 1 ? 's' : ''}.\n\n` + + `This will download the first ~10 MB of each clip from your PS5 and extract a frame.\n` + + `With many files this can take several minutes.\n\nContinue?` + ); + if (!ok) return; + } + + const btn = $('btn-clip-thumbs'); + btn.disabled = true; + btn.textContent = 'Fetching…'; + _setThumbStatusMsg(`Starting thumbnail fetch for ${missing.length} clip${missing.length !== 1 ? 's' : ''}…`); + + try { + await window.pork.mediaFetchClipThumbs(missing.map(c => c.remotePath)); + } catch (err) { + setStatus(`Clip thumbnail fetch failed: ${err.message}`, 'error'); + _setThumbStatusMsg(null); + btn.disabled = false; + btn.textContent = '\u{1F3AC} Fetch Clip Thumbnails'; + } +}); + +// ── Scan buttons ────────────────────────────────────────────────────────────── +async function runMediaScan(force = false) { + const btn = $('btn-media-scan'); + const forceBtn = $('btn-media-force-scan'); + const cancelBtn = $('btn-media-cancel-scan'); + const status = $('media-scan-status'); + btn.disabled = true; + forceBtn.disabled = true; + cancelBtn.hidden = !window.pork.mediaScanCancel; + btn.textContent = force ? 'Volledige scan…' : 'Scanning…'; + status.textContent = 'Connecting to PS5…'; + status.hidden = false; + + // Clear old data + caches + _mediaItems = []; + _thumbCache.clear(); + _thumbFetching.clear(); + _thumbQueued = 0; + _thumbDone = 0; + const tsEl = $('media-thumb-status'); + if (tsEl) tsEl.hidden = true; + renderMediaGrid(); + + try { + const result = await window.pork.mediaScan({ force }); + const packet = Array.isArray(result) ? { items: result, stats: { total: result.length }, scan: { mode: 'full' }, delta: {} } : result; + _mediaItems = Array.isArray(packet?.items) ? packet.items : []; + const mode = packet.cached ? 'cache' : packet.scan?.mode === 'incremental' ? 'incrementeel' : 'volledig'; + const delta = packet.delta || {}; + status.textContent = `${_mediaItems.length} items · ${mode} · +${delta.added || 0} / -${delta.removed || 0} / changed ${delta.changed || 0}${packet.scan?.reusedDirectories ? ` · ${packet.scan.reusedDirectories} folders reused` : ''}${packet.scan?.errors?.length ? ` · ${packet.scan.errors.length} FTP error(s), previous contents preserved` : ''}`; + status.style.color = ''; + // Restore persisted clip thumbnails so they show immediately after scan + try { + const diskCache = await window.pork.mediaLoadClipThumbs(); + for (const [rp, du] of Object.entries(diskCache || {})) _thumbCache.set(rp, du); + } catch (_) {} + renderMediaGrid(); + } catch (e) { + status.textContent = `Scan failed: ${e.message}`; + status.style.cssText += ';color:var(--red)'; + status.style.animationName = 'none'; + } finally { + btn.disabled = false; + forceBtn.disabled = false; + cancelBtn.hidden = true; + btn.textContent = '↺ Scan via FTP'; + } +} + +$('btn-media-scan').addEventListener('click', () => runMediaScan(false)); +$('btn-media-force-scan').addEventListener('click', () => runMediaScan(true)); +$('btn-media-cancel-scan').addEventListener('click', async event => { + event.currentTarget.disabled = true; + try { await window.pork.mediaScanCancel(); $('media-scan-status').textContent = 'Scan annuleren…'; } + catch (error) { setStatus(error.message, 'error'); } + finally { event.currentTarget.disabled = false; } +}); + +// Per-card thumb state updates from main process +window.pork.on('media:thumb:progress', ({ remotePath, state }) => { + const labels = { downloading: 'Downloading…', decoding: 'Processing…' }; + if (labels[state]) _setCardLabel(remotePath, labels[state]); +}); + +// Live scan progress +window.pork.on('media:scan:progress', ({ game_id, type, found, scannedDirectories, reusedDirectories }) => { + const status = $('media-scan-status'); + if (!status.hidden) { + status.textContent = `Scanning ${type === 'screenshot' ? 'screenshots' : 'video clips'}… ${found} found${game_id ? ` (${game_id})` : ''}${scannedDirectories ? ` · ${scannedDirectories} folders read` : ''}${reusedDirectories ? ` · ${reusedDirectories} reused` : ''}`; + } +}); + +// Discord share progress — update the share button live for every pipeline stage +// Track remote paths whose discord share has fully resolved so late progress +// events (which can race against the IPC reply) don't override the final state. +const _discordCompleted = new Set(); + +window.pork.on('media:discord:progress', ({ remotePath, state, pct, attempt, max }) => { + if (_discordCompleted.has(remotePath)) return; + const btn = document.querySelector(`.btn-media-share[data-remote="${CSS.escape(remotePath)}"]`); + if (!btn) return; + + if (state === 'downloading') { + btn.classList.remove('uploading'); + btn.textContent = pct != null ? `Downloading ${pct}%` : 'Downloading…'; + } else if (state === 'converting') { + btn.classList.remove('uploading'); + btn.textContent = pct != null ? `Converting ${pct}%` : 'Converting…'; + } else if (state === 'uploading-video') { + btn.classList.add('uploading'); + btn.style.setProperty('--upload-pct', `${pct ?? 0}%`); + btn.textContent = pct != null ? `Uploading ${pct}%` : 'Uploading…'; + } else if (state === 'retrying') { + btn.classList.remove('uploading'); + btn.style.removeProperty('--upload-pct'); + btn.textContent = `Retrying… (${attempt}/${max})`; + } else { + btn.classList.remove('uploading'); + btn.style.removeProperty('--upload-pct'); + const labels = { sending: 'Sending…', uploading: 'Uploading…' }; + if (labels[state]) btn.textContent = labels[state]; + } +}); + +// Discord share thumbnail — ffmpeg extracted a frame from the local mp4; cache it +// and patch the clip card immediately so the user sees the thumbnail right away. +window.pork.on('media:discord:thumb', ({ remotePath, dataUrl }) => { + if (!remotePath || !dataUrl) return; + _thumbCache.set(remotePath, dataUrl); + // Find every clip card for this path and swap the background → actual thumb + document.querySelectorAll(`.media-card[data-remote="${CSS.escape(remotePath)}"]`).forEach(card => { + const wrap = card.querySelector('.media-card-thumb'); + if (!wrap) return; + // Replace the WEBM badge placeholder with a real thumbnail image + const badge = wrap.querySelector('.clip-type-badge'); + if (badge) badge.remove(); + let img = wrap.querySelector('.media-thumb-img'); + if (!img) { + img = document.createElement('img'); + img.className = 'media-thumb-img'; + img.alt = ''; + img.dataset.remote = remotePath; + wrap.insertBefore(img, wrap.firstChild); + } + img.src = dataUrl; + img.classList.add('thumb-loaded'); + img.classList.remove('thumb-loading'); + }); +}); + +// Save path browse +$('btn-media-save-browse').addEventListener('click', async () => { + const folder = await window.pork.selectFolder(); + if (!folder) return; + $('media-save-path').value = folder; + await window.pork.setSettings({ mediaLocalPath: folder }); +}); + +// Open media folder in Explorer +$('btn-media-open-folder').addEventListener('click', () => { + const folder = $('media-save-path').value; + if (folder) window.pork.openShell(folder); + else setStatus('No media folder configured — select one first.', 'error'); +}); + +// Live update when main process falls back to a default path (e.g. configured drive missing) +window.pork.on('settings:updated', (patch) => { + if (patch?.mediaLocalPath) { + const el = $('media-save-path'); + if (el) el.value = patch.mediaLocalPath; + setStatus(`Media save folder updated to "${patch.mediaLocalPath}" (previous path was inaccessible).`, 'ok'); + } +}); + +// Sort dropdown +$('media-sort').addEventListener('change', e => { + _mediaSort = e.target.value; + renderMediaGrid(); +}); + +// Filter buttons +document.querySelectorAll('[data-mfilter]').forEach(btn => + btn.addEventListener('click', () => { + document.querySelectorAll('[data-mfilter]').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + _mediaFilter = btn.dataset.mfilter; + renderMediaGrid(); + }) +); + +// Search +$('media-search').addEventListener('input', () => { + clearTimeout(_mediaSearchTimer); + _mediaSearchTimer = setTimeout(() => { + _mediaSearch = $('media-search').value.trim(); + renderMediaGrid(); + }, 250); +}); + +// ── Lightbox ────────────────────────────────────────────────────────────────── +let _lightboxItem = null; // current { remotePath, game_id, filename } + +function openLightbox(item) { + _lightboxItem = item; + const box = $('media-lightbox'); + const img = $('lightbox-img'); + const spin = $('lightbox-spinner'); + const meta = $('lightbox-meta'); + + img.src = ''; + img.hidden = true; + spin.hidden = false; + meta.textContent = `${item.game_id} — ${item.filename}`; + box.hidden = false; + document.body.classList.add('lightbox-open'); + + const cached = _thumbCache.get(item.remotePath); + if (cached) { + img.src = cached; + img.hidden = false; + spin.hidden = true; + } else { + window.pork.mediaFetchThumb(item.remotePath) + .then(dataUrl => { + _thumbCache.set(item.remotePath, dataUrl); + if (_lightboxItem?.remotePath === item.remotePath) { + img.src = dataUrl; + img.hidden = false; + spin.hidden = true; + } + }) + .catch(err => { + spin.hidden = true; + meta.textContent += ` — Load failed: ${err.message}`; + }); + } +} + +function closeLightbox() { + $('media-lightbox').hidden = true; + document.body.classList.remove('lightbox-open'); + $('lightbox-img').src = ''; + _lightboxItem = null; +} + +$('btn-lightbox-close').addEventListener('click', closeLightbox); +$('media-lightbox').addEventListener('click', e => { + if (e.target === $('media-lightbox') || e.target.classList.contains('media-lightbox-backdrop')) closeLightbox(); +}); +document.addEventListener('keydown', e => { + if (e.key === 'Escape' && !$('media-lightbox').hidden) closeLightbox(); +}); + +$('btn-lightbox-dl').addEventListener('click', async () => { + if (!_lightboxItem) return; + const btn = $('btn-lightbox-dl'); + btn.disabled = true; + btn.textContent = 'Queued'; + try { + await window.pork.mediaDownload({ + remotePath: _lightboxItem.remotePath, + game_id: _lightboxItem.game_id, + filename: _lightboxItem.filename, + }); + setStatus(`Queued: ${_lightboxItem.filename} — see Transfers`, 'ok'); + } catch (err) { + setStatus(err.message, 'error'); + } finally { + setTimeout(() => { btn.disabled = false; btn.textContent = '↓ Save to PC'; }, 2000); + } +}); + +// ── Grid click delegation ───────────────────────────────────────────────────── +$('media-grid').addEventListener('click', async e => { + // Share to Discord button + const shareBtn = e.target.closest('.btn-media-share'); + if (shareBtn) { + shareBtn.disabled = true; + shareBtn.textContent = 'Preparing…'; + try { + const res = await window.pork.mediaDiscordSend({ + remotePath: shareBtn.dataset.remote, + game_id: shareBtn.dataset.game, + filename: shareBtn.dataset.file, + type: shareBtn.dataset.type, + }); + _discordCompleted.add(shareBtn.dataset.remote); + shareBtn.classList.remove('uploading'); + shareBtn.style.removeProperty('--upload-pct'); + shareBtn.textContent = '\u2713 Sent!'; + const detail = res?.url ? ` • ${res.url}` : ''; + setStatus(`Shared to Discord: ${shareBtn.dataset.file}${detail}`, 'ok'); + setTimeout(() => { + shareBtn.disabled = false; + shareBtn.textContent = '\uD83D\uDD17 Discord'; + _discordCompleted.delete(shareBtn.dataset.remote); + }, 3000); + } catch (err) { + _discordCompleted.add(shareBtn.dataset.remote); + shareBtn.classList.remove('uploading'); + shareBtn.style.removeProperty('--upload-pct'); + shareBtn.disabled = false; + shareBtn.textContent = '\uD83D\uDD17 Discord'; + _discordCompleted.delete(shareBtn.dataset.remote); + setStatus(`Discord share failed: ${err.message}`, 'error'); + } + return; + } + + // Download button + const dlBtn = e.target.closest('.btn-media-dl'); + if (dlBtn) { + dlBtn.disabled = true; + dlBtn.textContent = 'Queued'; + try { + await window.pork.mediaDownload({ + remotePath: dlBtn.dataset.remote, + game_id: dlBtn.dataset.game, + filename: dlBtn.dataset.file, + }); + setStatus(`Queued: ${dlBtn.dataset.file} — see Transfers`, 'ok'); + } catch (err) { + setStatus(err.message, 'error'); + } + dlBtn.disabled = false; + dlBtn.textContent = '\u21D3 Download'; + return; + } + + // Play button (video clips) + const playBtn = e.target.closest('.btn-media-play'); + if (playBtn) { + playBtn.disabled = true; + playBtn.textContent = 'Opening\u2026'; + try { + await window.pork.mediaOpen(playBtn.dataset.remote); + } catch (err) { + setStatus(err.message, 'error'); + } finally { + setTimeout(() => { playBtn.disabled = false; playBtn.textContent = '\u25B6 Play'; }, 1500); + } + return; + } + + // Click on a screenshot thumbnail → open lightbox + const card = e.target.closest('.media-card[data-type="screenshot"]'); + if (card && !e.target.closest('button')) { + const remote = card.dataset.remote; + const mediaItem = _mediaItems.find(i => i.remotePath === remote); + if (mediaItem) openLightbox(mediaItem); + } +}); + diff --git a/renderer/modules/modal.js b/renderer/modules/modal.js new file mode 100644 index 0000000..121e22f --- /dev/null +++ b/renderer/modules/modal.js @@ -0,0 +1,1527 @@ +for (const href of ['styles/ps4-packages.css', 'styles/ps5-native-packages.css']) { + if (!document.head.querySelector(`link[href="${href}"]`)) { + const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = href; document.head.appendChild(link); + } +} + +document.querySelectorAll('.modal-tab').forEach(tab => + tab.addEventListener('click', () => { + document.querySelectorAll('.modal-tab').forEach(t => { t.classList.remove('active'); t.setAttribute('aria-selected', 'false'); t.tabIndex = -1; }); + document.querySelectorAll('.modal-panel').forEach(p => { p.classList.remove('active'); p.hidden = true; }); + tab.classList.add('active'); + tab.setAttribute('aria-selected', 'true'); + tab.tabIndex = 0; + const panel = $(`mpanel-${tab.dataset.mtab}`); + panel.classList.add('active'); + panel.hidden = false; + }) +); + +function resetModalTabs() { + document.querySelectorAll('.modal-tab').forEach(t => { t.classList.remove('active'); t.setAttribute('aria-selected', 'false'); t.tabIndex = -1; }); + document.querySelectorAll('.modal-panel').forEach(p => { p.classList.remove('active'); p.hidden = true; }); + const first = document.querySelector('.modal-tab[data-mtab="info"]'); + first.classList.add('active'); first.setAttribute('aria-selected', 'true'); first.tabIndex = 0; + $('mpanel-info').classList.add('active'); $('mpanel-info').hidden = false; +} + +// Promote the four facts people open the dialog for; the full table still follows. +function renderModalMetrics(g, infoRows) { + let host = $('modal-metrics'); + if (!$('mpanel-info')) return; + if (!host) { + host = document.createElement('div'); + host.id = 'modal-metrics'; host.className = 'modal-metrics'; + $('mpanel-info').insertBefore(host, $('modal-info')); + } + const at = k => (infoRows.find(r => r[0] === k) || [])[1] || '—'; + const on = window.chimeraLibraryModel.isInstalled(g), copy = at('Local Copy') === 'Yes'; + host.innerHTML = [ + ['Size', at('Size'), ''], ['Version', at('Version'), ''], + ['On console', on ? 'Installed' : 'Not installed', on ? 'ok' : 'idle'], + ['Local copy', copy ? 'Backed up' : 'None', copy ? 'ok' : 'warn'], + ].map(([l, v, t]) => ``).join(''); +} +function renderProsperoData(g) { + const hasProspero = !!g.prospero_fetched_at; + + const banner = $('modal-banner'); + $('modal').style.setProperty('--modal-hue', String(titleHue(g.game_id))); + const art = g.prospero_banner_url || g.prospero_cover_url || g.prospero_icon_url || ''; + const setArt = url => { banner.style.backgroundImage = url ? `url('${url}')` : ''; banner.classList.toggle('has-art', !!url); }; + setArt(''); + if (art) { const i = new Image(); i.onload = () => setArt(art); i.src = art; } + + const icon = $('modal-icon'); + icon.onerror = () => { icon.hidden = true; }; + if (g.prospero_icon_url) { + icon.src = g.prospero_icon_url; + icon.hidden = false; + } else { + icon.hidden = true; + icon.src = ''; + } + + const patchCount = hasProspero ? (g.prospero_patch_count || 0) : 0; + const dlcArr = hasProspero ? tryParseJson(g.prospero_dlc, []) : []; + const regionArr = hasProspero ? tryParseJson(g.prospero_other_regions, []) : []; + $('mtab-patch-count').textContent = patchCount || ''; + $('mtab-dlc-count').textContent = dlcArr.length || ''; + $('mtab-regions-count').textContent = regionArr.length || ''; + + if (!hasProspero) { + $('modal-patches-content').innerHTML = '
    Loading update data…
    '; + $('modal-dlc-content').innerHTML = '
    Loading DLC data…
    '; + $('modal-regions-content').innerHTML = '
    Loading region data…
    '; + return; + } + + $('modal-description').hidden = true; + + const patches = tryParseJson(g.prospero_patches, []); + if (patches.length) { + $('modal-patches-content').innerHTML = ` +
      + ${patches.map(p => ` +
    • +
      + v${escHtml(p.contentVer || '?')} + ${p.isLatest ? 'LATEST' : ''} + + ${escHtml(p.filesize || '—')} + FW ${escHtml(p.requiredFirmware || '?')} + ${escHtml(p.importDate || '')} + +
      + ${p.changelogPreview ? `
      ${escHtml(p.changelogPreview)}
      ` : ''} +
    • `).join('')} +
    `; + } else { + $('modal-patches-content').innerHTML = '
    No patch updates found
    '; + } + + const dlc = dlcArr; + if (dlc.length) { + $('modal-dlc-content').innerHTML = ` +
      + ${dlc.map(d => ` +
    • + ${d.iconUrl ? `` : '
      '} +
      +
      ${escHtml(d.name || d.contentid || '—')}
      +
      + ${d.contentVer ? `v${escHtml(d.contentVer)}` : ''} + ${d.filesize ? ` · ${escHtml(d.filesize)}` : ''} + ${d.requiredFirmware ? ` · FW ${escHtml(d.requiredFirmware)}` : ''} +
      +
      +
    • `).join('')} +
    `; + } else { + $('modal-dlc-content').innerHTML = '
    No additional content
    '; + } + + const regions = regionArr; + if (regions.length) { + $('modal-regions-content').innerHTML = ` +
    + ${regions.map(r => { + const isOwned = r.titleid === g.game_id; + return ` + `; + }).join('')} +
    `; + $('modal-regions-content').querySelector('.region-pills')?.addEventListener('click', e => { + const pill = e.target.closest('[data-tid]'); + if (pill) openModal(pill.dataset.tid); + }); + } else { + $('modal-regions-content').innerHTML = '
    No other regions found
    '; + } +} + +function tryParseJson(str, fallback) { + try { return str ? JSON.parse(str) : fallback; } catch (_) { return fallback; } +} + + +const convJobsCache = new Map(); + +function patchConvJobCard(job) { + convJobsCache.set(job.id, job); + const card = document.querySelector(`.conv-job-row[data-conv-id="${job.id}"]`); + if (!card || card.dataset.convStatus !== job.status) { + refreshConvQueuePanel().catch(() => {}); + return; + } + const pct = job.progress?.percent ?? 0; + const bar = card.querySelector('.conv-job-bar'); + if (bar) bar.style.width = `${pct}%`; + + const phaseEl = card.querySelector('.conv-job-phase'); + if (phaseEl) phaseEl.textContent = job.progress?.phase || job.status; + + const log = (job.progress?.log ?? []).join('\n'); + const showLog = (job.status === 'running' || job.status === 'error') && log; + let lb = card.querySelector('.conv-log-box'); + if (showLog) { + if (!lb) { + lb = document.createElement('pre'); + lb.className = 'conv-log-box'; + lb.dataset.logLen = '0'; + card.appendChild(lb); + } + if (lb.dataset.logLen !== String(job.progress?.log?.length)) { + lb.textContent = log; + lb.scrollTop = lb.scrollHeight; + lb.dataset.logLen = String(job.progress?.log?.length ?? 0); + } + } +} + +async function refreshConvQueuePanel() { + try { + const jobs = await window.pork.convQueueList(); + const paused = await window.pork.convQueueIsPaused(); + const s = await window.pork.getSettings(); + const panel = $('conv-queue-panel'); + if (!panel) return; + panel.hidden = false; + + const mode = s.gameConversionMode || 'pfs'; + + document.querySelectorAll('.conv-mode-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.convMode === mode); + }); + + const warnEl = $('conv-mode-warn'); + const warnTxt = $('conv-mode-warn-text'); + let warnMsg = ''; + if (mode === 'ffpkg') { + const info = await window.pork.convToolInfo().catch(() => null); + warnMsg = info && !info.supported + ? info.reason + : (!s.ufs2ToolPath ? 'FFPKG mode requires UFS2Tool.exe — set the path in Game Conversion settings.' : ''); + } else if (mode === 'exfat') { + const info = await window.pork.convExfatToolInfo().catch(() => null); + warnMsg = info && !info.supported + ? info.reason + : (!s.osfmountPath ? 'ExFAT mode requires OSFMount — configure it in Game Conversion settings.' : ''); + } + if (warnEl) { + warnEl.hidden = !warnMsg; + if (warnTxt) warnTxt.textContent = warnMsg; + } + if (mode === 'exfat') { + window.pork.convExfatToolInfo().then(info => { + if (!warnEl) return; + if (info && !info.supported) { + warnEl.hidden = false; + if (warnTxt) warnTxt.textContent = info.reason; + return; + } + if (info && info.toolAvailable) { warnEl.hidden = true; return; } + warnEl.hidden = false; + if (warnTxt) warnTxt.textContent = 'ExFAT mode needs OSFMount — install it or set osfmount.com in Game Conversion settings.'; + }).catch(() => {}); + } + + const adminWarnEl = $('conv-admin-warn'); + if (adminWarnEl) { + if (mode === 'ffpkg' || mode === 'exfat') { + let isElevated = false; + try { isElevated = await window.pork.isAdmin(); } catch (_) {} + adminWarnEl.hidden = isElevated; + } else { + adminWarnEl.hidden = true; + } + } + + const listEl = $('conv-queue-list'); + const emptyEl = $('conv-queue-empty'); + if (listEl) { + listEl.innerHTML = jobs.map(j => renderConvJobRowHtml(j)).join(''); + jobs.forEach(j => convJobsCache.set(j.id, j)); + listEl.querySelectorAll('.conv-log-box').forEach(lb => { lb.scrollTop = lb.scrollHeight; }); + } + if (emptyEl) emptyEl.hidden = jobs.length > 0; + + const startBtn = $('btn-conv-start'); + if (startBtn) { + const hasQueued = jobs.some(j => j.status === 'queued'); + startBtn.disabled = !paused || !hasQueued; + startBtn.textContent = paused ? '▶ Start Queue' : '⏸ Running…'; + } + } catch (_) {} +} + +$('conv-queue-panel').addEventListener('click', async e => { + const modeBtn = e.target.closest('.conv-mode-btn'); + if (modeBtn) { + const mode = modeBtn.dataset.convMode; + await window.pork.setSettings({ gameConversionMode: mode }); + const radio = document.querySelector(`#conv-mode-${mode}`); + if (radio) radio.checked = true; + applyConvModeUI(mode); + await refreshConvQueuePanel(); + return; + } + if (e.target.closest('#btn-conv-goto-settings')) { + navigate('settings'); + setTimeout(() => { + const card = $('settings-conversion-card'); + if (card) card.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, 100); + } +}); + +function renderConvJobRowHtml(job) { + const pct = job.progress?.percent ?? 0; + const phase = escHtml(job.progress?.phase || job.status); + const log = (job.progress?.log ?? []).join('\n'); + const showLog = (job.status === 'running' || job.status === 'error') && log; + const barClass = job.status === 'done' ? 'conv-bar-done' + : job.status === 'error' ? 'conv-bar-error' + : job.status === 'running' ? 'conv-bar-running' + : ''; + const cancelBtn = (job.status === 'queued' || job.status === 'running') + ? `` + : ''; + const retryBtn = (job.status === 'error' || job.status === 'cancelled') + ? `` + : ''; + return `
    +
    + #${job.id} + ${escHtml(job.game_name || job.game_id)} + ${escHtml((job.mode || '').toUpperCase())} + ${phase} +
    + ${escHtml(job.status)} +
    ${cancelBtn}${retryBtn}
    +
    + ${showLog ? `
    ${escHtml(log)}
    ` : ''} + ${job.error ? `
    ✗ ${escHtml(job.error)}
    ` : ''} +
    `; +} + +function showAdminWarning() { + openAccessibleDialog($('admin-warn-overlay'), $('admin-warn-close')); +} +$('admin-warn-close').addEventListener('click', () => { + closeAccessibleDialog($('admin-warn-overlay')); +}); +$('admin-warn-overlay').addEventListener('click', (e) => { + if (e.target === $('admin-warn-overlay')) closeAccessibleDialog($('admin-warn-overlay')); +}); + +async function guardAdminForConv() { + const s = await window.pork.getSettings(); + const mode = s.gameConversionMode || 'pfs'; + if (mode === 'pfs' || mode === 'ffpfsc') return true; + let ok = false; + try { + ok = await window.pork.isAdmin(); + } catch (_) { + ok = false; + } + if (!ok) { showAdminWarning(); return false; } + return true; +} + +$('btn-conv-start').addEventListener('click', async () => { + if (!await guardAdminForConv()) return; + await window.pork.convQueueStart(); + await refreshConvQueuePanel(); +}); + +$('btn-conv-clear-done').addEventListener('click', async () => { + await window.pork.convQueueClearDone(); + await refreshConvQueuePanel(); +}); + +$('btn-conv-cleanup-temp').addEventListener('click', async () => { + const btn = $('btn-conv-cleanup-temp'); + btn.disabled = true; + btn.textContent = '\u{1F9F9} Cleaning\u2026'; + try { + const res = await window.pork.convQueueCleanupTemp(); + const parts = []; + if (res.deletedDirs) parts.push(`${res.deletedDirs} temp folder${res.deletedDirs > 1 ? 's' : ''}`); + if (res.deletedFiles) parts.push(`${res.deletedFiles} partial file${res.deletedFiles > 1 ? 's' : ''}`); + btn.textContent = parts.length ? `\u2713 Removed ${parts.join(' + ')}` : '\u2713 Nothing to clean'; + if (res.errors?.length) console.warn('[CleanTemp]', res.errors); + } catch (e) { + btn.textContent = '\u2717 Cleanup failed'; + } + setTimeout(() => { btn.disabled = false; btn.textContent = '\u{1F9F9} Clean Temp'; }, 3000); +}); + +$('btn-conv-queue-toggle').addEventListener('click', () => { + const body = $('conv-queue-body'); + const collapsed = body.hidden; + body.hidden = !collapsed; + $('btn-conv-queue-toggle').textContent = collapsed ? '↑' : '↓'; +}); + +$('conv-queue-list').addEventListener('click', async e => { + const cancelBtn = e.target.closest('[data-conv-cancel]'); + if (cancelBtn) { + await window.pork.convQueueCancel(Number(cancelBtn.dataset.convCancel)); + await refreshConvQueuePanel(); + return; + } + const retryBtn = e.target.closest('[data-conv-retry]'); + if (retryBtn) { + await window.pork.convQueueRetry(Number(retryBtn.dataset.convRetry)); + await refreshConvQueuePanel(); + } +}); + + +let _installBackporkEntries = []; + +async function openInstallModal(game_id, startMode) { + const [games, s] = await Promise.all([ + window.pork.listLibraryTitles({ search: game_id, limit: 10 }).then(window.chimeraLibraryModel.items), + window.pork.getSettings(), + ]); + const g = games.find(x => x.game_id === game_id); + if (!g) return; + const remotePaths = Array.isArray(s.remoteGamePaths) ? s.remoteGamePaths : []; + + $('install-modal-game-label').textContent = g.prospero_name || g.title || game_id; + $('install-overlay').dataset.gameId = game_id; + $('install-overlay').dataset.gameName = g.prospero_name || g.title || game_id; + + const backups = await window.pork.listBackups(game_id); + const localBackup = backups.find(b => b.source === 'local' && b.backup_type === 'folder') + ?? backups.find(b => b.source === 'local') + ?? backups[0]; + const latest = localBackup; + $('install-overlay').dataset.backupPath = latest?.backup_path || ''; + + const hintEl = $('install-backup-hint'); + if (hintEl) { + if (latest) { + hintEl.textContent = `Source: ${latest.backup_path}`; + hintEl.title = latest.backup_path; + } else { + hintEl.textContent = 'No local backup found — use Browse folder to select one'; + hintEl.title = ''; + } + } + + let ftpPath = g.ftp_path || ''; + const noPathsConfigured = remotePaths.length === 0; + if (!ftpPath && !noPathsConfigured) { + const base = remotePaths[0].path.replace(/\/?$/, '/'); + ftpPath = base + game_id + '-app'; + } + const ftpPathEl = $('install-ftp-path'); + if (ftpPathEl) ftpPathEl.value = ftpPath; + const warn = $('install-no-path-warn'); + if (warn) warn.hidden = !!ftpPath; + $('install-overlay').dataset.requiredBytes = String(Number(latest?.size || 0)); + if (window.chimeraInstallDestination) { + await window.chimeraInstallDestination.load({ titleId: game_id, required: Number(latest?.size || 0), legacyPath: ftpPath }); + } + + const entries = await window.pork.backporksEntries(game_id); + _installBackporkEntries = entries; + const bpSel = $('install-backpork-select'); + if (bpSel) { + bpSel.innerHTML = '' + + entries.map((e, i) => ``).join(''); + } + + const convHintEl = $('install-conv-backup-hint'); + if (convHintEl) { + if (latest) { + convHintEl.textContent = `Source: ${latest.backup_path}`; + convHintEl.title = latest.backup_path; + } else { + convHintEl.textContent = 'No local backup found — use Browse folder to select one'; + convHintEl.title = ''; + } + } + + const convBpSel = $('install-conv-backpork-select'); + if (convBpSel) { + convBpSel.innerHTML = '' + + entries + .map((e, i) => ({ e, i })) + .filter(({ e }) => !!e.game_path) // skip firmware folders with no patch for this game + .map(({ e, i }) => ``) + .join(''); + } + + const convFtpPathEl = $('install-conv-ftp-path'); + if (convFtpPathEl) { + let convDir = ''; + if (g.ftp_path) { + convDir = g.ftp_path.replace(/\/[^\/]+\/?$/, '') || '/'; + if (!convDir.endsWith('/')) convDir += '/'; + } else if (!noPathsConfigured) { + convDir = remotePaths[0].path.replace(/\/?$/, '/'); + } + convFtpPathEl.value = convDir; + } + + const convFtpUploadEl = $('install-conv-ftp-upload'); + if (convFtpUploadEl) { + convFtpUploadEl.checked = !!s.convFtpUpload; + const convFtpFields = $('install-conv-ftp-fields'); + if (convFtpFields) convFtpFields.style.display = convFtpUploadEl.checked ? 'block' : 'none'; + } + const convDelEl = $('install-conv-delete-after'); + if (convDelEl) convDelEl.checked = !!s.convDeleteAfter; + + const convToolWarn = $('install-conv-tool-warn'); + if (convToolWarn) { + const currentMode = s.gameConversionMode || 'pfs'; + if (currentMode === 'ffpkg') { + const info = s.ufs2ToolPath ? await window.pork.convToolInfo().catch(() => null) : null; + convToolWarn.hidden = !!info?.toolAvailable; + } else if (currentMode === 'exfat') { + const info = await window.pork.convExfatToolInfo().catch(() => null); + convToolWarn.hidden = !!info?.toolAvailable; + } else { + convToolWarn.hidden = true; + } + } + + const requestedMode = ['pfs', 'ffpkg', 'ffpfsc', 'exfat'].includes(startMode) ? startMode : null; + const currentMode = requestedMode || s.gameConversionMode || 'pfs'; + _applyInstallModeTab(currentMode); + + updateInstallConfirmState(currentMode, latest, ftpPath); + openAccessibleDialog($('install-overlay'), $('install-close')); +} + +function _applyInstallModeTab(mode) { + document.querySelectorAll('.install-mode-tab').forEach(t => + t.classList.toggle('active', t.dataset.installMode === mode) + ); + const pfsSec = $('install-pfs-section'); + const convSec = $('install-conv-section'); + if (pfsSec) pfsSec.hidden = mode !== 'pfs'; + if (convSec) convSec.hidden = mode === 'pfs'; +} + +function updateInstallConfirmState(mode, latest, ftpPath) { + const confirmBtn = $('btn-install-confirm'); + if (!confirmBtn) return; + if (mode === 'pfs') { + const backupPath = $('install-overlay').dataset.backupPath; + const destinationReady = window.chimeraInstallDestination?.isReady() || false; + confirmBtn.disabled = !backupPath || !state.connected || !destinationReady; + } else { + const backupPath = $('install-overlay').dataset.backupPath; + confirmBtn.disabled = !backupPath; + } +} + +$('install-overlay').addEventListener('click', e => { + if (e.target === $('install-overlay')) { + closeAccessibleDialog($('install-overlay')); + return; + } + const tab = e.target.closest('.install-mode-tab'); + if (!tab) return; + const mode = tab.dataset.installMode; + _applyInstallModeTab(mode); + updateInstallConfirmState(mode); +}); + +$('btn-install-browse-local').addEventListener('click', async () => { + const folder = await window.pork.selectFolder(); + if (!folder) return; + $('install-overlay').dataset.backupPath = folder; + $('install-backup-hint').textContent = `Source: ${folder}`; + $('install-backup-hint').title = folder; + const convHint = $('install-conv-backup-hint'); + if (convHint) { convHint.textContent = `Source: ${folder}`; convHint.title = folder; } + const ftpPath = $('install-ftp-path').value.trim(); + $('btn-install-confirm').disabled = !ftpPath || !state.connected; + const gameId = $('install-overlay').dataset.gameId; + if (gameId) window.pork.addBackup(gameId, folder).catch(() => {}); +}); + +$('btn-install-browse-ftp').addEventListener('click', async () => { + $('install-expert-destination').open = true; + const current = $('install-ftp-path').value.replace('(searching PS5…)', '') || '/'; + const selected = await openFtpBrowser(current); + if (selected) $('install-ftp-path').value = selected; +}); + +$('btn-install-confirm').addEventListener('click', async () => { + const game_id = $('install-overlay').dataset.gameId; + const backupPath = $('install-overlay').dataset.backupPath; + + const activeTab = document.querySelector('.install-mode-tab.active'); + const mode = activeTab?.dataset.installMode || 'pfs'; + + if (!backupPath) { setStatus('No local backup found for this game', 'error'); return; } + + $('btn-install-confirm').disabled = true; + $('btn-install-confirm').textContent = 'Queueing…'; + + try { + if (mode === 'pfs') { + const destination = window.chimeraInstallDestination?.selection(); + if (!destination) { setStatus('Select an eligible destination volume or open the Expert raw-path override', 'error'); return; } + const ftpPath = destination.remotePath; + if (!state.connected){ setStatus('Connect to PS5 first', 'error'); return; } + + const bpIdx = $('install-backpork-select').value; + let pork = null; + if (bpIdx !== '') { + const bp = _installBackporkEntries[Number(bpIdx)]; + if (bp) pork = { folder_name: bp.folder_name, folder_path: bp.folder_path }; + } + + await window.pork.ftpInstallPfs({ + game_id, + localPath: backupPath, + remotePath: ftpPath, + destinationVolumeId: destination.destinationVolumeId, + expertRawPath: destination.expertRawPath, + pork, + }); + await window.pork.updateGame({ game_id, fields: { ftp_path: ftpPath } }); + + setStatus('Install queued — see Transfers tab for progress', 'ok'); + + } else { + const ftpUploadCb = $('install-conv-ftp-upload'); + const ftpUpload = ftpUploadCb?.checked ?? false; + const ftpPath = ftpUpload ? ($('install-conv-ftp-path')?.value?.trim() || '') : ''; + const deleteAfter = ftpUpload && ($('install-conv-delete-after')?.checked ?? false); + + const bpIdx = $('install-conv-backpork-select')?.value ?? ''; + let backpork_path = ''; + let firmware_label = ''; + if (bpIdx !== '') { + const bp = _installBackporkEntries[Number(bpIdx)]; + if (bp && bp.game_path) { backpork_path = bp.game_path; firmware_label = bp.folder_name; } + } + + const game_name = $('install-overlay').dataset.gameName || game_id; + await window.pork.convQueueAdd({ + game_id, + game_name, + game_path: backupPath, + backpork_path, + firmware_label, + mode, + ftpUpload, + ftpRemotePath: ftpPath, + deleteAfter, + }); + + await refreshConvQueuePanel(); + setStatus(`${mode.toUpperCase()} conversion queued — see Conversion Queue on Games page`, 'ok'); + } + } catch (e) { + setStatus('Queue failed: ' + e.message, 'error'); + } finally { + $('install-overlay').hidden = true; + $('btn-install-confirm').disabled = false; + $('btn-install-confirm').textContent = '⬆ Queue Install'; + } +}); + +['install-close', 'btn-install-cancel'].forEach(id => + $(id).addEventListener('click', () => closeAccessibleDialog($('install-overlay'))) +); +$('install-overlay').addEventListener('keydown', event => { + if (event.key === 'Escape') closeAccessibleDialog($('install-overlay')); +}); + +$('install-conv-ftp-upload').addEventListener('change', function () { + const fields = $('install-conv-ftp-fields'); + if (fields) fields.style.display = this.checked ? 'block' : 'none'; +}); + +$('btn-install-conv-browse-local').addEventListener('click', async () => { + const folder = await window.pork.selectFolder(); + if (!folder) return; + $('install-overlay').dataset.backupPath = folder; + const hint = $('install-backup-hint'); + const convHint = $('install-conv-backup-hint'); + if (hint) { hint.textContent = `Source: ${folder}`; hint.title = folder; } + if (convHint) { convHint.textContent = `Source: ${folder}`; convHint.title = folder; } + updateInstallConfirmState(document.querySelector('.install-mode-tab.active')?.dataset.installMode || 'pfs'); + const gameId = $('install-overlay').dataset.gameId; + if (gameId) window.pork.addBackup(gameId, folder).catch(() => {}); +}); + +$('btn-install-conv-browse-ftp').addEventListener('click', async () => { + const current = $('install-conv-ftp-path')?.value?.replace('(searching PS5…)', '') || '/'; + const selected = await openFtpBrowser(current); + if (selected && $('install-conv-ftp-path')) $('install-conv-ftp-path').value = selected; +}); + +function isPs5NativeWorkspaceTitle(gameId) { + return /^PPSA\d{5}$/.test(String(gameId || '').toUpperCase()) || String(gameId || '').toUpperCase() === 'FAKE00000'; +} + +function selectPs5RestoreQualificationOperation(installs, titleId) { + const expectedTitleId = String(titleId || '').toUpperCase(); + const matching = (Array.isArray(installs) ? installs : []).filter(item => String(item?.titleId || '').toUpperCase() === expectedTitleId); + const launchRelevant = matching.filter(item => ['installed_observed', 'launch_test_pending', 'launching', 'launch_verified', 'launch_failed'].includes(item.status) + || (item.status === 'interrupted' && item.launchOutcome === 'outcome_unknown')); + const candidates = launchRelevant.length ? launchRelevant : matching; + return candidates.sort((a, b) => Number(b.updatedAt || b.createdAt || 0) - Number(a.updatedAt || a.createdAt || 0))[0] || null; +} + +async function openModal(game_id) { + try { + const games = window.chimeraLibraryModel.items(await window.pork.listLibraryTitles({ search: game_id, limit: 10 })); + const g = games.find(x => x.game_id === game_id); + if (!g) return; + state.currentGame = g; + + if (window.chimeraTitleWorkspace) window.chimeraTitleWorkspace.render(g); + resetModalTabs(); + + const displayName = g.prospero_name || g.title || g.game_id; + $('modal-title').textContent = displayName; + $('modal-subtitle').textContent = `${g.game_id} · ${g.prospero_region || g.version ? (g.prospero_region || '') + (g.version ? ' v' + g.version : '') : 'version unknown'}`; + + const patches = tryParseJson(g.prospero_patches, []); + const fwVersions = [...new Set(patches.map(p => p.requiredFirmware).filter(Boolean))]; + const fwDisplay = fwVersions.length ? fwVersions.join(', ') : '—'; + const dlc = tryParseJson(g.prospero_dlc, []); + const infoRows = [ + ['Game ID', g.game_id], + ['Content ID', g.content_id || g.prospero_content_id || '—'], + ['Version', g.version || g.prospero_version || '—'], + ['Size', g.backup_size ? fmt(g.backup_size) : (g.size ? fmt(g.size) : (g.prospero_size || '—'))], + ['Installed', window.chimeraLibraryModel.isInstalled(g) ? 'Yes' : 'No'], + ['Local Copy', window.chimeraLibraryModel.hasLocalCopy(g) ? 'Yes' : 'No'], + ['Prepared for firmware', g.porked_firmware ? `${g.porked_firmware}${g.porked_at ? ' (' + new Date(g.porked_at).toLocaleDateString() + ')' : ''}` : '—'], + ['Publisher', g.prospero_publisher || '—'], + ['Publisher ID', g.prospero_publisher_id || '—'], + ['Region', g.prospero_region || '—'], + ['Last Updated', g.prospero_last_updated || '—'], + ['Required Firmware', fwDisplay], + ['DLC Available', dlc.length ? `${dlc.length} item(s)` : 'None'], + ['FTP Path', g.ftp_path || '—'], + ['Last Scanned', formatGameTimestamp(g.last_scanned)], + ['Metadata Age', g.prospero_fetched_at ? fmtAge(Date.now() - g.prospero_fetched_at) : 'Never fetched'], + ]; + renderModalMetrics(g, infoRows); + $('modal-info').innerHTML = infoRows.map(([k, v]) => `${escHtml(k)}${escHtml(v)}`).join(''); + + const backups = await window.pork.listBackups(game_id); + const localBackups = backups.filter(b => b.source !== 'backpork'); + $('modal-backup-list').innerHTML = localBackups.length + ? localBackups.map(b => `
  • ${escHtml(b.backup_path)}${fmt(b.size)}
  • `).join('') + : '
  • No backups registered
  • '; + + // Legacy hash/manage endpoints intentionally accept retail CUSA/PPSA IDs only. + // The exact pinned FAKE00000 launch reference still needs its guarded native + // package workspace, but must not be routed through those unrelated endpoints. + if (/^(?:CUSA|PPSA)\d{5}$/.test(game_id)) { + await renderHashSection(game_id, backups); + await renderManagePanel(g, backups); + } else { + $('modal-hash-section').hidden = true; + $('manage-backpork-section').hidden = true; + } + + renderProsperoData(g); + + renderCheatsPanel(g); + + $('btn-modal-backup').disabled = true; // enabled async below if folder found on FTP + $('btn-modal-install').disabled = !state.connected || !backups.length; + $('btn-modal-ps4-packages').hidden = !/^CUSA\d{5}$/.test(game_id); + $('btn-modal-ps5-native-packages').hidden = !isPs5NativeWorkspaceTitle(game_id); + if (typeof openAccessibleDialog === 'function') openAccessibleDialog($('modal-overlay'), $('modal-close')); + else $('modal-overlay').hidden = false; + + if (state.connected) { + window.pork.ftpFindGame(game_id).then(ftpPath => { + if (ftpPath) { + $('btn-modal-backup').disabled = false; + if (ftpPath !== g.ftp_path) { + window.pork.updateGame({ game_id, fields: { ftp_path: ftpPath } }); + state.currentGame.ftp_path = ftpPath; + } + } + }).catch(() => {}); + } + } catch (e) { + setStatus(e.message, 'error'); + } +} + +function fmtAge(ms) { + if (ms < 60000) return 'Just now'; + if (ms < 3600000) return `${Math.floor(ms / 60000)}m ago`; + if (ms < 86400000) return `${Math.floor(ms / 3600000)}h ago`; + return `${Math.floor(ms / 86400000)}d ago`; +} + +function formatGameTimestamp(value) { + if (value == null || value === '') return '—'; + const numeric = Number(value); + const date = Number.isFinite(numeric) ? new Date(numeric) : new Date(value); + return Number.isNaN(date.getTime()) ? 'Unknown' : date.toLocaleString(); +} + +$('btn-modal-mark').hidden = true; + +function ps4PackageText(key, fallback) { return typeof window.t === 'function' ? window.t(key, fallback) : fallback; } +function setPs4PackageReadiness(message, stateName = '') { + const element = $('ps4-package-readiness'); + element.textContent = message; + element.dataset.state = stateName; +} +async function refreshPs4PackageWorkspace() { + const game = state.currentGame; + if (!game || !/^CUSA\d{5}$/.test(game.game_id)) return; + const [builder, jobs, packageData] = await Promise.all([ + window.pork.ps4PackageBuilderStatus(), + window.pork.ps4PackageJobs(), + window.pork.packageRecipesList(game.id), + ]); + setPs4PackageReadiness(builder.available + ? ps4PackageText('ps4Packages.builder.ready', `Builder ${builder.id} ${builder.version} is ready.`) + : ps4PackageText('ps4Packages.builder.blocked', 'The pinned PS4 builder is not configured. Add its folder in Settings before building.'), builder.available ? 'ready' : 'blocked'); + $('ps4-package-create').disabled = !builder.available || !$('ps4-package-base').value; + const sets = (packageData.packageSets || []).filter(item => item.platform === 'ps4' && item.components?.some(component => component.type === 'base')); + $('ps4-package-set').innerHTML = sets.length + ? sets.map(item => ``).join('') + : ``; + $('ps4-package-restore').disabled = !sets.length; + const titleJobs = jobs.filter(job => job.titleId === game.game_id).sort((a, b) => Number(b.createdAt) - Number(a.createdAt)); + $('ps4-package-jobs').innerHTML = titleJobs.length ? titleJobs.map(job => ` +
    + ${escHtml(job.titleId)} · ${job.children.length} PKG${escHtml(job.status)} + ${escHtml(job.children.map(child => `${child.type} ${child.version || ''}`).join(' · '))} + ${['failed', 'interrupted'].includes(job.status) ? `` : ''} +
    `).join('') : `

    ${escHtml(ps4PackageText('ps4Packages.jobs.empty', 'No package activity for this title yet.'))}

    `; + window.i18n?.apply($('ps4-packages-overlay')); +} +async function openPs4PackageWorkspace() { + const game = state.currentGame; + if (!game || !/^CUSA\d{5}$/.test(game.game_id)) return; + $('ps4-packages-game').textContent = `${game.prospero_name || game.title || game.game_id} · ${game.game_id}`; + const registeredBackups = await window.pork.listBackups(game.game_id).catch(() => []); + const folderBackup = registeredBackups.find(item => item.source !== 'backpork' && item.backup_type === 'folder'); + $('ps4-package-base').value = folderBackup?.backup_path || ''; + $('ps4-package-update').value = ''; + $('ps4-package-create').disabled = true; + if (typeof openAccessibleDialog === 'function') openAccessibleDialog($('ps4-packages-overlay'), $('ps4-packages-close')); + else $('ps4-packages-overlay').hidden = false; + try { await refreshPs4PackageWorkspace(); } catch (error) { setPs4PackageReadiness(error.message, 'blocked'); } +} +function closePs4PackageWorkspace() { + if (typeof closeAccessibleDialog === 'function') closeAccessibleDialog($('ps4-packages-overlay')); + else $('ps4-packages-overlay').hidden = true; +} + +$('btn-modal-ps4-packages').addEventListener('click', openPs4PackageWorkspace); +['ps4-packages-close', 'ps4-packages-done'].forEach(id => $(id).addEventListener('click', closePs4PackageWorkspace)); +['base', 'update'].forEach(type => $(`ps4-package-${type}-browse`).addEventListener('click', async () => { + const folder = await window.pork.selectFolder(); + if (!folder) return; + $(`ps4-package-${type}`).value = folder; + $('ps4-package-create').disabled = !$('ps4-package-base').value; +})); +$('ps4-package-refresh').addEventListener('click', () => refreshPs4PackageWorkspace().catch(error => setPs4PackageReadiness(error.message, 'blocked'))); +$('ps4-package-create').addEventListener('click', async () => { + const game = state.currentGame; + const sources = [{ sourcePath: $('ps4-package-base').value, componentType: 'base' }]; + if ($('ps4-package-update').value) sources.push({ sourcePath: $('ps4-package-update').value, componentType: 'update' }); + $('ps4-package-create').disabled = true; + setPs4PackageReadiness(ps4PackageText('ps4Packages.create.running', 'Validating dump evidence and building verified packages…')); + try { + const job = await window.pork.ps4PackageCreate({ expectedTitleId: game.game_id, sources }); + await window.pork.ps4PackageRun(job.id); + setPs4PackageReadiness(ps4PackageText('ps4Packages.create.completed', 'PKG backup completed, hash-verified and registered.'), 'ready'); + await refreshPs4PackageWorkspace(); + } catch (error) { setPs4PackageReadiness(error.message, 'blocked'); } + finally { $('ps4-package-create').disabled = !$('ps4-package-base').value; } +}); +$('ps4-package-restore').addEventListener('click', async () => { + const game = state.currentGame; + const packageSetId = Number($('ps4-package-set').value); + if (!packageSetId) return; + try { + const plan = await window.pork.ps4PackageRestorePlan({ titleId: game.id, packageSetId }); + const detail = plan.orderedComponents.map(item => `${item.type.toUpperCase()} · ${item.version || '—'} · ${item.sha256.slice(0, 12)}…`).join('\n'); + const confirmation = await showExactConfirmation(`${ps4PackageText('ps4Packages.restore.confirm', 'Hand off this verified package set to etaHEN DPI in the exact order below?')}\n\n${detail}\n\n${ps4PackageText('ps4Packages.restore.boundary', 'A successful handoff is not installed evidence. A console scan remains required.')}`, 'INSTALL PS4 PACKAGES'); + if (!confirmation) return; + setPs4PackageReadiness(ps4PackageText('ps4Packages.restore.running', 'Sending ordered PKG handoffs…')); + const result = await window.pork.ps4PackageRestore({ titleId: game.id, packageSetId, confirmation }); + setPs4PackageReadiness(result.status === 'awaiting_post_scan' ? ps4PackageText('ps4Packages.restore.awaitingScan', 'Handoffs accepted. Installation is not yet verified; run the post-install scan.') : result.status, 'ready'); + } catch (error) { setPs4PackageReadiness(error.message, 'blocked'); } +}); +$('ps4-package-verify').addEventListener('click', async () => { + const game = state.currentGame; + setPs4PackageReadiness(ps4PackageText('ps4Packages.verify.running', 'Scanning the console for installed evidence…')); + try { + const result = await window.pork.ps4PackageRestoreVerify(game.game_id); + setPs4PackageReadiness(result.verified ? ps4PackageText('ps4Packages.verify.completed', 'Console scan verified the installation.') : ps4PackageText('ps4Packages.verify.notObserved', 'The console scan did not observe the installation yet.'), result.verified ? 'ready' : 'blocked'); + } catch (error) { setPs4PackageReadiness(error.message, 'blocked'); } +}); +$('ps4-package-jobs').addEventListener('click', async event => { + const button = event.target.closest('[data-ps4-resume]'); if (!button) return; + try { await window.pork.ps4PackageResume(button.dataset.ps4Resume); await window.pork.ps4PackageRun(button.dataset.ps4Resume); await refreshPs4PackageWorkspace(); } + catch (error) { setPs4PackageReadiness(error.message, 'blocked'); } +}); + +function ps5NativePackageText(key, fallback) { return typeof window.t === 'function' ? window.t(key, fallback) : fallback; } +function ps5A10Text(key) { return ps5NativePackageText(`ps5NativePackages.a10Control.${key}`, key); } +function confirmPs5A10(prompt, boundary, phrase, evidence = '') { return showExactConfirmation(`${ps5A10Text(prompt)}${evidence ? `\n\n${evidence}` : ''}\n\n${ps5A10Text(boundary)}`, phrase); } +let ps5ConsoleDiscovery = null; +let ps5PreparedSources = null; +let ps5NativeInstallOperationId = null; +let ps5UpstreamControlOperationId = null; +let ps5LaunchReferenceOperationId = null; +let ps5A8ControlPlan = null; +let ps5A8ControlOperationId = null; +let ps5A10ControlPlan = null; +let ps5A10ControlOperationId = null; +let ps5P15R2LastPreflight = null; +let ps5P15R2QualificationAttempted = false; +const ps5Control = suffix => $(`ps5-upstream-control-${suffix}`); +const ps5A8Control = suffix => $(`ps5-a8-control-${suffix}`); +const ps5A10Control = suffix => $(`ps5-a10-control-${suffix}`); +const ps5LaunchReference = suffix => $(`ps5-launch-reference-${suffix}`); +function ps5SourcesFromStagedJob(job) { + if (!job?.stagedPath || !job?.manifest?.components) return null; + const sources = job.manifest.components.filter(item => item.packageBuild?.supported === true).map(item => ({ componentType: item.type, sourcePath: `${job.stagedPath}/components/${item.id}` })); + return sources.some(item => item.componentType === 'base') ? sources : null; +} +function setPs5NativePackageReadiness(message, stateName = '') { const element = $('ps5-native-package-readiness'); element.textContent = message; element.dataset.state = stateName; } +function setPs5FpkgEvidence(id, message, stateName = 'unknown') { const element = $(id); if (!element) return; element.textContent = message; element.dataset.state = stateName; } +function renderPs5RestoreQualification(operation) { + const launchButton = $('ps5-native-package-launch'); + if (!operation) { + setPs5FpkgEvidence('ps5-native-restore-evidence', ps5NativePackageText('ps5NativePackages.evidence.notInstalled', 'Not installed'), 'unproven'); + launchButton.disabled = true; + return; + } + ps5NativeInstallOperationId = operation.id; + if (operation.status === 'launch_verified') { + setPs5FpkgEvidence('ps5-native-restore-evidence', ps5NativePackageText('ps5NativePackages.launch.qualified', 'Installed · Launch verified · Qualified for restore'), 'qualified'); + launchButton.disabled = true; + } else if (operation.status === 'launch_failed') { + setPs5FpkgEvidence('ps5-native-restore-evidence', `${ps5NativePackageText('ps5NativePackages.launch.failed', 'Installed · launch failed · Not qualified for restore')} · ${operation.error?.code || 'PS5_FPKG_LAUNCH_UNVERIFIED'}`, 'failed'); + launchButton.disabled = true; + } else if (operation.status === 'launching') { + setPs5FpkgEvidence('ps5-native-restore-evidence', ps5NativePackageText('ps5NativePackages.launch.observing', 'Installed · observing exact title process…'), 'running'); + launchButton.disabled = true; + } else if (operation.status === 'interrupted' && operation.launchOutcome === 'outcome_unknown') { + setPs5FpkgEvidence('ps5-native-restore-evidence', ps5NativePackageText('ps5NativePackages.launch.unknown', 'Installed · launch outcome unknown · Not qualified'), 'failed'); + launchButton.disabled = true; + } else if (operation.status === 'installed_observed') { + setPs5FpkgEvidence('ps5-native-restore-evidence', ps5NativePackageText('ps5NativePackages.launch.pending', 'Installed · launch verification pending'), 'pending'); + launchButton.disabled = false; + } else { + setPs5FpkgEvidence('ps5-native-restore-evidence', ps5NativePackageText('ps5NativePackages.evidence.installPending', 'Installation not yet proven'), 'unproven'); + launchButton.disabled = true; + } +} +function renderPs5UpstreamControl(control = {}) { + const ready = control.status === 'ready'; const proven = control.installTrust?.status === 'post-scan-proven'; + ps5Control('trust').textContent = proven ? ps5NativePackageText('ps5NativePackages.control.proven', 'Post-scan proven') : ps5NativePackageText('ps5NativePackages.control.unproven', 'Unproven'); + ps5Control('trust').dataset.trust = proven ? 'post-scan-proven' : 'unproven'; + ps5Control('status').textContent = ready ? `${ps5NativePackageText('ps5NativePackages.control.ready', 'Pinned upstream control is verified and registered.')} · ${String(control.sha256 || '').slice(0, 12)}…` : ps5NativePackageText('ps5NativePackages.control.notImported', 'Control package not imported.'); + ps5Control('import').disabled = ready; + ps5Control('restore').disabled = !ready; + ps5Control('verify').disabled = !ps5UpstreamControlOperationId; +} +function renderPs5A8Control(control = {}, operation = null) { + const ready = control.status === 'ready'; const retry = control.status === 'built_verified'; const drift = control.status === 'artifact_drift'; + const awaitingScan = operation?.status === 'awaiting_post_scan'; const installed = operation?.status === 'installed_observed'; + const launching = operation?.status === 'launching'; const launchVerified = operation?.status === 'launch_verified'; const launchFailed = operation?.status === 'launch_failed'; + const installInFlight = ['preparing', 'staging', 'installing'].includes(operation?.status); + ps5A8Control('trust').removeAttribute('data-i18n'); + ps5A8Control('status').removeAttribute('data-i18n'); + ps5A8Control('trust').textContent = launchVerified ? ps5NativePackageText('ps5NativePackages.a8Control.launchVerified', 'Installed · launch verified') : launchFailed ? ps5NativePackageText('ps5NativePackages.a8Control.launchFailed', 'Installed · launch failed') : installed || launching ? ps5NativePackageText('ps5NativePackages.a8Control.installedPendingLaunch', 'Installed · launch unproven') : installInFlight || awaitingScan ? ps5NativePackageText('ps5NativePackages.a8Control.installUnprovenTrust', 'Install result · scan required') : ready ? ps5NativePackageText('ps5NativePackages.a8Control.readyTrust', 'Host ready · install unproven') : retry ? ps5NativePackageText('ps5NativePackages.a8Control.retryTrust', 'Verified · registration pending') : drift ? ps5NativePackageText('ps5NativePackages.a8Control.driftTrust', 'Artifact drift blocked') : ps5NativePackageText('ps5NativePackages.a8Control.unprepared', 'Not prepared'); + ps5A8Control('trust').dataset.trust = launchVerified ? 'qualified' : launchFailed ? 'failed' : installed || launching ? 'post-scan-proven' : installInFlight || awaitingScan ? 'pending' : drift ? 'failed' : 'unproven'; + ps5A8Control('status').textContent = launchVerified ? ps5NativePackageText('ps5NativePackages.a8Control.launchVerified', 'Installed and exact-title launch verified.') : launchFailed ? `${ps5NativePackageText('ps5NativePackages.a8Control.launchFailed', 'Installed, but launch qualification failed.')} · ${operation.error?.code || 'PS5_FPKG_LAUNCH_UNVERIFIED'}` : launching ? ps5NativePackageText('ps5NativePackages.a8Control.launching', 'Observing the exact CHIM00002 process…') : installed ? ps5NativePackageText('ps5NativePackages.a8Control.installedPendingLaunch', 'Installation proven. Exact-title launch remains unproven.') : awaitingScan ? ps5NativePackageText('ps5NativePackages.a8Control.awaitingScan', 'Installer completed. Run the independent CHIM00002 post-scan.') : installInFlight ? ps5NativePackageText('ps5NativePackages.a8Control.installing', 'Installing the exact CHIM00002 control…') : ready ? `${ps5NativePackageText('ps5NativePackages.a8Control.ready', 'Frozen metadata-qualified CHIM00002 artifact is verified and registered. Installation and launch remain unproven.')} · ${String(control.sha256 || '').slice(0, 12)}…` : retry ? ps5NativePackageText('ps5NativePackages.a8Control.registrationPending', 'Artifact verified; retry host registration without rebuilding.') : drift ? ps5NativePackageText('ps5NativePackages.a8Control.drift', 'Selected artifact changed and is blocked.') : ps5NativePackageText('ps5NativePackages.a8Control.notPrepared', 'Frozen artifact is included but not registered.'); + const active = installInFlight || awaitingScan || installed || launching || launchVerified; + ps5A8Control('prepare').disabled = ready || drift || control.status === 'server-required'; + ps5A8Control('preflight').disabled = !ready || active || control.status === 'server-required'; + ps5A8Control('install').disabled = !ready || !ps5A8ControlPlan || active; + ps5A8Control('verify').disabled = !awaitingScan; + ps5A8Control('launch').disabled = !installed; +} +function renderPs5A10Control(control = {}, operation = null, runtime = {}) { + const ui = ps5A10Control; + const op = operation?.status || ''; const ready = control.status === 'ready'; const drift = control.status === 'artifact_drift'; + const scan = op === 'awaiting_post_scan'; const ins = op === 'installed_observed'; const run = op === 'launching'; + const flight = ['preparing', 'staging', 'installing'].includes(op); + const active = runtime?.diagnostic?.engine?.available === true && runtime.diagnostic.engine.processOwned === true; + const exact = active && runtime?.diagnosticIdentity?.status === 'exact' && runtime.diagnosticIdentity.mutationAllowed === true; + const runtimeEligible = ['installed_observed', 'launch_failed', 'launch_verified'].includes(op); + const launchEligible = op === 'installed_observed'; + const state = op === 'launch_verified' ? 'launchVerified' : op === 'launch_failed' ? 'launchFailed' : op === 'interrupted' ? 'launchInterrupted' : run ? 'launching' : exact ? 'runtimeExact' : active ? 'runtimeActive' : ins ? 'installedPendingRuntime' : scan ? 'awaitingScan' : flight ? 'installing' : ready ? 'ready' : control.status === 'built_verified' ? 'registrationPending' : drift ? 'drift' : 'notPrepared'; + const tk = { launchVerified: 'launchVerified', launchFailed: 'launchFailed', launchInterrupted: 'launchInterrupted', runtimeExact: 'runtimeExactTrust', runtimeActive: 'runtimeActiveTrust', installedPendingRuntime: 'installedPendingRuntime', awaitingScan: 'installUnprovenTrust', installing: 'installUnprovenTrust', ready: 'readyTrust', registrationPending: 'retryTrust', drift: 'driftTrust', notPrepared: 'unprepared', launching: 'installedPendingRuntime' }[state]; + const t = ui('trust'); const status = ui('status'); + t.removeAttribute('data-i18n'); status.removeAttribute('data-i18n'); t.textContent = ps5A10Text(tk); + t.dataset.trust = state === 'launchVerified' ? 'qualified' : ['launchFailed', 'drift'].includes(state) ? 'failed' : active || ins || run ? 'post-scan-proven' : scan || flight ? 'pending' : 'unproven'; + const sk = state === 'launchVerified' ? 'launchVerifiedDetail' : state === 'launchFailed' ? 'launchFailedDetail' : state === 'launchInterrupted' ? 'launchInterruptedDetail' : state; + status.textContent = `${ps5A10Text(sk)}${state === 'launchFailed' ? ` · ${operation.error?.code || 'PS5_FPKG_LAUNCH_UNVERIFIED'}` : state === 'ready' ? ` · ${String(control.sha256 || '').slice(0, 12)}…` : ''}`; + const s = $('ps5-a10-control-evidence-summary'); const id = String(operation?.id || ''); + if (s.dataset.operationId !== id) { s.hidden = true; s.textContent = ''; s.dataset.operationId = id; } + const busy = flight || scan || ins || run || op.startsWith('launch_'); + ui('prepare').disabled = ready || drift || control.status === 'server-required'; + ui('preflight').disabled = !ready || busy || control.status === 'server-required'; + ui('install').disabled = !ready || !ps5A10ControlPlan || busy; + ui('verify').disabled = !scan; + ui('stage').disabled = !runtimeEligible || active; ui('payload').disabled = !runtimeEligible || !active || exact; + ui('launch').disabled = !launchEligible || !exact; + ui('restore').disabled = !active; + ui('evidence').disabled = !operation?.launchEvidence || !['launch_failed', 'launch_verified', 'interrupted'].includes(op); +} +function renderPs5P15R2Qualification(status = {}, preflight = ps5P15R2LastPreflight) { + const matrix = Array.isArray(preflight?.matrix) ? preflight.matrix : []; + const body = $('ps5-p15-r2-matrix'); + body.innerHTML = matrix.length + ? matrix.map(row => { + const kstuff = row.gate === 'kstuff' ? preflight?.evidence?.kstuff : null; + const evidence = kstuff + ? `${escHtml(JSON.stringify({ reason: row.reason || null, ...kstuff }))}` + : escHtml(row.reason || 'exact'); + return `${escHtml(row.gate)}${escHtml(row.result)}${evidence}`; + }).join('') + : `${escHtml(ps5NativePackageText('ps5NativePackages.r4.notCollected', 'No current matrix collected.'))}`; + const profileCreated = Boolean(status.profile); + const budget = status.budget || { state: 'blocked', used: 0, total: 1 }; + const ready = preflight?.allGatesPass === true; + const label = profileCreated + ? ps5NativePackageText('ps5NativePackages.r4.ready', 'READY · immutable profile created · launch remains locked') + : preflight + ? ready ? ps5NativePackageText('ps5NativePackages.r4.allPass', 'All gates PASS · qualification is available') : ps5NativePackageText('ps5NativePackages.r4.blocked', 'Preflight blocked · qualification remains disabled') + : ps5NativePackageText('ps5NativePackages.r4.statusPending', 'Read-only status loaded; live preflight has not run.'); + $('ps5-p15-r2-state').textContent = `${label} · ${budget.used || 0}/${budget.total || 1}`; + $('ps5-p15-r2-qualify').disabled = !ready || profileCreated || budget.state === 'consumed' || ps5P15R2QualificationAttempted; +} +function renderPs5LaunchReference(reference = {}) { + const ready = reference.status === 'ready'; const installed = reference.installTrust?.status === 'post-scan-proven'; + ps5LaunchReference('trust').textContent = installed ? ps5NativePackageText('ps5NativePackages.launchReference.launchRequired', 'Installed · launch required') : ps5NativePackageText('ps5NativePackages.launchReference.unproven', 'Unproven'); + ps5LaunchReference('trust').dataset.trust = installed ? 'post-scan-proven' : 'unproven'; + ps5LaunchReference('status').textContent = ready ? `${ps5NativePackageText('ps5NativePackages.launchReference.ready', 'Verified websrv launcher.')} · ${String(reference.sha256 || '').slice(0, 12)}…` : ps5NativePackageText('ps5NativePackages.launchReference.notImported', 'Not imported.'); + ps5LaunchReference('import').disabled = ready; + ps5LaunchReference('restore').disabled = !ready; + ps5LaunchReference('verify').disabled = !ps5LaunchReferenceOperationId; +} +async function monitorPs5ReferenceInstall(operationId, { active, element, key, label, awaiting, dpiAwaiting, completed }) { + while (active() === operationId) { + await new Promise(resolve => setTimeout(resolve, 2000)); + let operation; + try { operation = await window.pork.ps5NativePackageRestoreStatus(operationId); } + catch (error) { element('status').textContent = error.message; return; } + const component = operation.components?.[operation.currentIndex]; + if (operation.status === 'staging' || operation.status === 'installing') { + const progress = operation.status === 'staging' ? Number(component?.stagingProgress?.percent || 0) : (() => { const total = Number(component?.total || component?.size || 0); return total > 0 ? Math.min(100, Math.floor((Number(component?.downloaded || component?.installedBytes || 0) / total) * 100)) : 0; })(); + element('status').textContent = `${ps5NativePackageText(`ps5NativePackages.${key}.${operation.status}`, `${operation.status === 'staging' ? 'Staging' : 'Installing'} ${label}`)} · ${progress}%`; + continue; + } + if (operation.status === 'awaiting_post_scan') { element('status').textContent = component?.acceptedUnverified && dpiAwaiting ? ps5NativePackageText(`ps5NativePackages.${key}.dpiAwaitingScan`, dpiAwaiting) : ps5NativePackageText(`ps5NativePackages.${key}.awaitingScan`, awaiting); element('verify').disabled = false; return; } + if (operation.status === 'installed_observed' && completed) { element('status').textContent = completed; return; } + element('status').textContent = operation.error?.message || operation.status; return; + } +} +async function monitorPs5A8ControlLaunch(operationId) { + while (ps5A8ControlOperationId === operationId) { + await new Promise(resolve => setTimeout(resolve, 2000)); + let operation; + try { operation = await window.pork.ps5NativePackageRestoreLaunchStatus(operationId); } + catch (error) { ps5A8Control('status').textContent = error.message; return; } + renderPs5A8Control({ status: 'ready' }, operation); + if (operation.status === 'launch_verified' || operation.status === 'launch_failed') return; + if (operation.status !== 'launching') { ps5A8Control('status').textContent = operation.error?.message || operation.status; return; } + } +} +async function monitorPs5A10ControlLaunch(operationId) { + while (ps5A10ControlOperationId === operationId) { + await new Promise(resolve => setTimeout(resolve, 2000)); + let operation; let runtime; + try { + [operation, runtime] = await Promise.all([ + window.pork.ps5NativePackageRestoreLaunchStatus(operationId), + window.pork.ps5P15A10('runtime-status'), + ]); + } catch (error) { ps5A10Control('status').textContent = error.message; return; } + renderPs5A10Control({ status: 'ready' }, operation, runtime); + if (operation.status === 'launch_verified' || operation.status === 'launch_failed') return; + if (operation.status !== 'launching') { ps5A10Control('status').textContent = operation.error?.message || operation.status; return; } + } +} +async function monitorPs5NativePackageInstall(operationId) { + while (ps5NativeInstallOperationId === operationId) { + await new Promise(resolve => setTimeout(resolve, 2000)); + let operation; + try { operation = await window.pork.ps5NativePackageRestoreStatus(operationId); } + catch (error) { setPs5NativePackageReadiness(error.message, 'blocked'); return; } + const component = operation.components?.[operation.currentIndex]; + if (operation.status === 'staging') { + $('ps5-native-package-cancel').disabled = true; + const percent = Number(component?.stagingProgress?.percent || 0); + setPs5NativePackageReadiness(`${ps5NativePackageText('ps5NativePackages.restore.staging', 'Uploading verified PS5 .pkg to console staging')} · ${String(component?.type || '').toUpperCase()} · ${percent}%`, 'running'); + continue; + } + if (operation.status === 'installing') { + $('ps5-native-package-cancel').disabled = false; + const total = Number(component?.total || component?.size || 0); const transferred = Number(component?.downloaded || component?.installedBytes || 0); + const percent = total > 0 ? Math.min(100, Math.floor((transferred / total) * 100)) : 0; + setPs5NativePackageReadiness(`${ps5NativePackageText('ps5NativePackages.restore.installing', 'Installing verified PS5 .pkg')} · ${String(component?.type || '').toUpperCase()} · ${percent}%`, 'running'); + continue; + } + if (operation.status === 'awaiting_post_scan') { + $('ps5-native-package-cancel').disabled = true; + setPs5NativePackageReadiness(component?.acceptedUnverified ? ps5NativePackageText('ps5NativePackages.restore.dpiAwaitingScan', 'Standalone DPI accepted the package. Only the independent console scan can prove installation.') : ps5NativePackageText('ps5NativePackages.restore.awaitingScan', 'PS5Upload verified completion. Run the independent console scan before treating it as installed.'), 'ready'); + return; + } + $('ps5-native-package-cancel').disabled = true; + if (operation.status === 'installed_observed') { renderPs5RestoreQualification(operation); setPs5NativePackageReadiness(ps5NativePackageText('ps5NativePackages.verify.completedPendingLaunch', 'Installation proven. Launch verification is still required.'), 'ready'); return; } + setPs5NativePackageReadiness(operation.error?.message || operation.status, 'blocked'); return; + } +} +async function monitorPs5NativePackageBuild(jobId) { + const phaseKeys = { + queued: ['ps5NativePackages.phase.queued', 'Queued'], + source_validation: ['ps5NativePackages.phase.sourceValidation', 'Validating source evidence'], + component_build: ['ps5NativePackages.phase.componentBuild', 'Building package components'], + artifact_verification: ['ps5NativePackages.phase.artifactVerification', 'Validating FIH and CNT structures'], + built: ['ps5NativePackages.phase.built', 'Artifacts built'], + registering: ['ps5NativePackages.phase.registering', 'Registering verified artifacts'], + registration_pending: ['ps5NativePackages.phase.registrationPending', 'Registration retry queued'], + }; + for (;;) { + await new Promise(resolve => setTimeout(resolve, 2000)); + let job; + try { job = await window.pork.ps5NativePackageJob(jobId); } + catch (error) { setPs5NativePackageReadiness(error.message, 'blocked'); return; } + if (job.status === 'artifact_registered') { + await refreshPs5NativePackageWorkspace(); + setPs5NativePackageReadiness(ps5NativePackageText('ps5NativePackages.create.completed', 'PS5 fPKG set is structurally verified and registered. Install and launch remain unproven.'), 'ready'); return; + } + if (job.status === 'built_unregistered') { + await refreshPs5NativePackageWorkspace(); + setPs5NativePackageReadiness(job.error || ps5NativePackageText('ps5NativePackages.create.unregistered', 'Package artifacts are safe on disk, but registration failed. Resume retries registration without rebuilding.'), 'blocked'); return; + } + if (['failed', 'interrupted'].includes(job.status)) { + await refreshPs5NativePackageWorkspace(); + setPs5NativePackageReadiness(job.error || job.status, 'blocked'); return; + } + const [key, fallback] = phaseKeys[job.phase] || ['ps5NativePackages.phase.running', 'Build in progress']; + const component = job.componentProgress?.find(item => ['building', 'verifying'].includes(item.status)); + const suffix = component ? ` · ${String(component.type || '').toUpperCase()}` : ''; + setPs5NativePackageReadiness(`${ps5NativePackageText(key, fallback)}${suffix}`, 'running'); + } +} +function ps5NativePackageSetOptionLabel(item) { + const component = item.components?.[0] || {}; + const version = component.version || component.metadata?.version?.value || '—'; + const digest = String(component.sha256 || '').slice(0, 12) || '—'; + return `${item.label || item.setKey} · v${version} · ${digest}… · #${item.id}`; +} +function renderPs5PackageComparison(result) { + const trustText = trust => trust.status === 'post-scan-proven' + ? ps5NativePackageText('ps5NativePackages.compare.proven', 'Post-scan proven') + : trust.status === 'appinst-rejected' + ? `${ps5NativePackageText('ps5NativePackages.compare.rejected', 'AppInst rejected')} · 0x${Number(trust.appInstError || 0).toString(16).toUpperCase()}` + : ps5NativePackageText('ps5NativePackages.compare.unproven', 'Trust unproven'); + const differenceRows = result.differences.length + ? result.differences.map(item => `
  • ${escHtml(item.component)}${escHtml(item.kind)} · ${escHtml(item.fields.join(', '))}
  • `).join('') + : `
  • ${escHtml(ps5NativePackageText('ps5NativePackages.compare.identical', 'No material artifact difference'))}${escHtml(ps5NativePackageText('ps5NativePackages.compare.identicalDetail', 'Retrying the same bytes cannot change the known AppInst outcome.'))}
  • `; + $('ps5-package-comparison-result').innerHTML = `
    ${escHtml(ps5NativePackageText('ps5NativePackages.compare.candidate', 'Candidate'))}${escHtml(trustText(result.candidateTrust))}${escHtml(ps5NativePackageText('ps5NativePackages.compare.reference', 'Reference'))}${escHtml(trustText(result.referenceTrust))}
      ${differenceRows}

    ${escHtml(ps5NativePackageText(`ps5NativePackages.compare.next.${result.nextAction}`, result.nextAction))}

    `; +} +async function refreshPs5NativePackageWorkspace() { + const game = state.currentGame; if (!game || !isPs5NativeWorkspaceTitle(game.game_id)) return; + const [builder, jobs, stageJobs, packageData, control, a8Control, a10Control, a10Runtime, launchReference, installs, r2Status] = await Promise.all([window.pork.ps5NativePackageBuilderStatus(), window.pork.ps5NativePackageJobs(), window.pork.ps5ConsoleStagingJobs(), window.pork.packageRecipesList(game.id), window.pork.ps5UpstreamControl('status'), window.pork.ps5A8DistinctControl('status'), window.pork.ps5A10TransitionControl('status'), window.pork.ps5P15A10('runtime-status'), window.pork.ps5LaunchReference('status'), window.pork.ps5NativePackageRestoreList(), window.pork.ps5P15R2Status()]); + const awaiting = (installs || []).filter(item => item.status === 'awaiting_post_scan').sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0)); + const a8Operation = (installs || []).filter(item => item.titleId === 'CHIM00002' + && Number(item.packageSetId) === Number(a8Control.packageSetId) + && item.components?.length === 1 + && item.components[0]?.sha256 === a8Control.sha256 + && Number(item.components[0]?.size) === Number(a8Control.size)).sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0))[0] || null; + const a10Operation = (installs || []).filter(item => item.titleId === 'CHIM00003' + && Number(item.packageSetId) === Number(a10Control.packageSetId) + && item.components?.length === 1 + && item.components[0]?.sha256 === a10Control.sha256 + && Number(item.components[0]?.size) === Number(a10Control.size)).sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0))[0] || null; + ps5UpstreamControlOperationId = awaiting.find(item => item.titleId === 'PPSA99099')?.id || null; + ps5LaunchReferenceOperationId = awaiting.find(item => item.titleId === 'FAKE00000')?.id || null; + ps5A8ControlOperationId = a8Operation?.id || null; + ps5A10ControlOperationId = a10Operation?.id || null; + if (a8Operation || a8Control.status !== 'ready') ps5A8ControlPlan = null; + if (a10Operation || a10Control.status !== 'ready') ps5A10ControlPlan = null; + renderPs5UpstreamControl(control); + renderPs5P15R2Qualification(r2Status); + renderPs5A8Control(a8Control, a8Operation); + renderPs5A10Control(a10Control, a10Operation, a10Runtime); + renderPs5LaunchReference(launchReference); + setPs5NativePackageReadiness(builder.available ? ps5NativePackageText('ps5NativePackages.builder.ready', 'The pinned native package bridge is ready.') : ps5NativePackageText('ps5NativePackages.builder.blocked', 'The revision-pinned LibProsperoPkg bridge is not configured.'), builder.available ? 'ready' : 'blocked'); + $('ps5-native-package-create').disabled = !builder.available || !$('ps5-native-package-source').value; + const sets = (packageData.packageSets || []).filter(item => item.platform === 'ps5' && item.manifest?.packageFormat?.container === 'FIH'); + $('ps5-native-package-set').innerHTML = sets.length ? sets.map(item => ``).join('') : ``; + const comparisonOptions = sets.map(item => ``).join(''); + $('ps5-package-comparison-candidate').innerHTML = comparisonOptions || ''; + $('ps5-package-comparison-reference').innerHTML = comparisonOptions || ''; + if (sets.length > 1) $('ps5-package-comparison-reference').value = String(sets[1].id); + $('ps5-package-comparison-run').disabled = sets.length < 2; + $('ps5-native-package-restore').disabled = !sets.length; + const titleJobs = jobs.filter(job => job.titleId === game.game_id).sort((a, b) => Number(b.createdAt) - Number(a.createdAt)); + const titleStageJobs = stageJobs.filter(job => job.titleId === game.game_id).sort((a, b) => Number(b.createdAt) - Number(a.createdAt)); + const qualificationOperation = selectPs5RestoreQualificationOperation(installs, game.game_id); + renderPs5RestoreQualification(qualificationOperation); + const captured = titleStageJobs.find(job => job.status === 'staged' && job.manifest?.components?.length); + if (captured) { + const components = captured.manifest.components; + const complete = components.every(item => item.preservation?.complete === true && item.preservation?.verified === true); + const files = components.reduce((sum, item) => sum + Number(item.preservation?.fileCount || item.fileCount || 0), 0); + setPs5FpkgEvidence('ps5-native-preservation-evidence', complete ? `${ps5NativePackageText('ps5NativePackages.evidence.backupSafe', 'Backup safe')} · ${files.toLocaleString()} ${ps5NativePackageText('ps5NativePackages.evidence.filesVerified', 'files verified')}` : ps5NativePackageText('ps5NativePackages.evidence.backupIncomplete', 'Backup incomplete'), complete ? 'safe' : 'failed'); + const baseReadiness = components.find(item => item.type === 'base')?.fpkgReadiness; + if (baseReadiness?.ready === true) { + const modules = Number(baseReadiness.modules?.total || 0); const unresolved = Number(baseReadiness.modules?.unresolved || 0); + setPs5FpkgEvidence('ps5-native-fpkg-readiness-evidence', `${ps5NativePackageText('ps5NativePackages.evidence.ready', 'Ready for fPKG')} · ${modules} ${ps5NativePackageText('ps5NativePackages.evidence.modulesInspected', 'modules inspected')} · ${unresolved} ${ps5NativePackageText('ps5NativePackages.evidence.unresolved', 'unresolved')}`, 'ready'); + } else { + const unresolved = Number(baseReadiness?.modules?.unresolved || 0); + setPs5FpkgEvidence('ps5-native-fpkg-readiness-evidence', `${ps5NativePackageText('ps5NativePackages.evidence.buildBlocked', 'fPKG build blocked')} · ${unresolved} ${ps5NativePackageText('ps5NativePackages.evidence.unresolved', 'unresolved')}`, 'blocked'); + } + } else { + setPs5FpkgEvidence('ps5-native-preservation-evidence', ps5NativePackageText('ps5NativePackages.evidence.notCaptured', 'Not captured'), 'unknown'); + setPs5FpkgEvidence('ps5-native-fpkg-readiness-evidence', ps5NativePackageText('ps5NativePackages.evidence.notAnalyzed', 'Not analyzed'), 'unknown'); + } + if (!ps5PreparedSources) ps5PreparedSources = ps5SourcesFromStagedJob(titleStageJobs.find(job => job.status === 'staged')); + if (ps5PreparedSources?.length) $('ps5-native-package-source').value = ps5PreparedSources.find(item => item.componentType === 'base')?.sourcePath || ''; + $('ps5-native-package-create').disabled = !builder.available || !$('ps5-native-package-source').value; + const stageRows = titleStageJobs.map(job => `
    ${escHtml(job.titleId)} · console capture${escHtml(job.status)}${escHtml(job.components.map(item => item.type).join(' + '))} · ${job.manifest ? escHtml(`${job.manifest.components.reduce((sum, item) => sum + item.fileCount, 0)} hashed files`) : escHtml(ps5NativePackageText('ps5NativePackages.stage.awaiting', 'awaiting complete inventory'))}${['failed', 'interrupted'].includes(job.status) ? `` : ''}
    `); + const packageRows = titleJobs.map(job => `
    ${escHtml(job.titleId)} · native .pkg${escHtml(job.status)}${escHtml(job.version || '—')} · ${escHtml(`${job.components?.length || 1} component(s)`)} · ${escHtml(job.phase || job.container?.validation?.validationLevel || 'awaiting FIH verification')}${['failed', 'interrupted', 'built_unregistered'].includes(job.status) ? `` : ''}
    `); + $('ps5-native-package-jobs').innerHTML = stageRows.length || packageRows.length ? [...stageRows, ...packageRows].join('') : `

    ${escHtml(ps5NativePackageText('ps5NativePackages.jobs.empty', 'No native package activity for this title yet.'))}

    `; + window.i18n?.apply($('ps5-native-packages-overlay')); +} +async function openPs5NativePackageWorkspace() { + const game = state.currentGame; if (!game || !isPs5NativeWorkspaceTitle(game.game_id)) return; + ps5ConsoleDiscovery = null; ps5PreparedSources = null; ps5A8ControlPlan = null; ps5A10ControlPlan = null; ps5P15R2LastPreflight = null; ps5P15R2QualificationAttempted = false; + const r4Card = $('ps5-p15-r2-card'); + r4Card.className = 'ps5-upstream-control ps5-p15-r2-qualification'; + r4Card.setAttribute('aria-labelledby', 'ps5-p15-r2-title'); + $('ps5-p15-r2-title').textContent = ps5NativePackageText('ps5NativePackages.r4.title', 'PPSA99099 read-only qualification'); + $('ps5-p15-r2-state').className = 'ps5-upstream-control-status'; + $('ps5-p15-r2-matrix').closest('table').className = 'ps5-p15-r2-matrix'; + $('ps5-p15-r2-preflight').className = 'btn btn-accent'; + $('ps5-p15-r2-preflight').textContent = ps5NativePackageText('ps5NativePackages.r4.preflight', 'Collect read-only preflight'); + $('ps5-p15-r2-qualify').className = 'btn btn-teal'; + $('ps5-p15-r2-qualify').textContent = ps5NativePackageText('ps5NativePackages.r4.qualify', 'Run one qualification'); + $('ps5-native-packages-game').textContent = `${game.prospero_name || game.title || game.game_id} · ${game.game_id}`; + const backups = await window.pork.listBackups(game.game_id).catch(() => []); const folder = backups.find(item => item.source !== 'backpork' && item.backup_type === 'folder'); + $('ps5-native-package-source').value = folder?.backup_path || ''; + if (typeof openAccessibleDialog === 'function') openAccessibleDialog($('ps5-native-packages-overlay'), $('ps5-native-packages-close')); else $('ps5-native-packages-overlay').hidden = false; + try { await refreshPs5NativePackageWorkspace(); } catch (error) { setPs5NativePackageReadiness(error.message, 'blocked'); } +} +function closePs5NativePackageWorkspace() { if (typeof closeAccessibleDialog === 'function') closeAccessibleDialog($('ps5-native-packages-overlay')); else $('ps5-native-packages-overlay').hidden = true; } +$('btn-modal-ps5-native-packages').addEventListener('click', openPs5NativePackageWorkspace); +['ps5-native-packages-close', 'ps5-native-packages-done'].forEach(id => $(id).addEventListener('click', closePs5NativePackageWorkspace)); +$('ps5-native-package-source-browse').addEventListener('click', async () => { const folder = await window.pork.selectFolder(); if (!folder) return; ps5PreparedSources = null; $('ps5-native-package-source').value = folder; await refreshPs5NativePackageWorkspace().catch(error => setPs5NativePackageReadiness(error.message, 'blocked')); }); +$('ps5-native-package-refresh').addEventListener('click', () => refreshPs5NativePackageWorkspace().catch(error => setPs5NativePackageReadiness(error.message, 'blocked'))); +$('ps5-package-comparison-run').addEventListener('click', async () => { + const game = state.currentGame; const candidatePackageSetId = Number($('ps5-package-comparison-candidate').value); const referencePackageSetId = Number($('ps5-package-comparison-reference').value); + if (!candidatePackageSetId || !referencePackageSetId) return; + $('ps5-package-comparison-run').disabled = true; + try { renderPs5PackageComparison(await window.pork.ps5PackageComparison({ titleId: game.id, candidatePackageSetId, referencePackageSetId })); } + catch (error) { $('ps5-package-comparison-result').innerHTML = `

    ${escHtml(error.message)}

    `; } + finally { $('ps5-package-comparison-run').disabled = false; } +}); +ps5Control('import').addEventListener('click', async () => { + ps5Control('import').disabled = true; ps5Control('status').textContent = ps5NativePackageText('ps5NativePackages.control.importing', 'Downloading and verifying the pinned upstream control…'); + try { renderPs5UpstreamControl(await window.pork.ps5UpstreamControl('import')); } + catch (error) { ps5Control('status').textContent = error.message; ps5Control('import').disabled = false; } +}); +ps5Control('restore').addEventListener('click', async () => { + const confirmation = await showExactConfirmation(`${ps5NativePackageText('ps5NativePackages.control.confirm', 'Install the exact hash-pinned PPSA99099 upstream control package?')}\n\n${ps5NativePackageText('ps5NativePackages.control.boundary', 'This changes the PS5. Only a fresh post-install console scan may prove installation.')}`, 'INSTALL PS5 UPSTREAM CONTROL PACKAGE'); + if (!confirmation) return; + ps5Control('restore').disabled = true; ps5Control('status').textContent = ps5NativePackageText('ps5NativePackages.control.starting', 'Starting the guarded upstream control installation…'); + try { const result = await window.pork.ps5UpstreamControl('restore', { confirmation }); ps5UpstreamControlOperationId = result.operationId; void monitorPs5ReferenceInstall(result.operationId, { active: () => ps5UpstreamControlOperationId, element: ps5Control, key: 'control', label: 'control package', awaiting: 'Installer completed. Run the independent control post-scan.', dpiAwaiting: 'Standalone DPI accepted the package. Only the independent control post-scan can prove installation.', completed: ps5NativePackageText('ps5NativePackages.control.proven', 'Post-scan proven') }); } + catch (error) { ps5Control('status').textContent = error.message; ps5Control('restore').disabled = false; } +}); +ps5Control('verify').addEventListener('click', async () => { + if (!ps5UpstreamControlOperationId) return; + ps5Control('verify').disabled = true; ps5Control('status').textContent = ps5NativePackageText('ps5NativePackages.control.scanning', 'Scanning the console for fresh PPSA99099 evidence…'); + try { const result = await window.pork.ps5UpstreamControl('restore-verify', { operationId: ps5UpstreamControlOperationId }); ps5Control('status').textContent = result.verified ? ps5NativePackageText('ps5NativePackages.control.proven', 'Post-scan proven') : ps5NativePackageText('ps5NativePackages.control.notObserved', 'The control package was not observed by the fresh console scan.'); ps5Control('trust').textContent = result.verified ? ps5NativePackageText('ps5NativePackages.control.proven', 'Post-scan proven') : ps5NativePackageText('ps5NativePackages.control.unproven', 'Unproven'); ps5Control('trust').dataset.trust = result.verified ? 'post-scan-proven' : 'unproven'; if (!result.verified) ps5Control('verify').disabled = false; } + catch (error) { ps5Control('status').textContent = error.message; ps5Control('verify').disabled = false; } +}); +ps5A8Control('prepare').addEventListener('click', async () => { + ps5A8ControlPlan = null; + ps5A8Control('prepare').disabled = true; + ps5A8Control('status').textContent = ps5NativePackageText('ps5NativePackages.a8Control.preparing', 'Verifying and registering the exact frozen control artifact…'); + try { renderPs5A8Control(await window.pork.ps5A8DistinctControl('prepare')); await refreshPs5NativePackageWorkspace(); } + catch (error) { ps5A8Control('status').textContent = error.message; ps5A8Control('prepare').disabled = false; } +}); +ps5A8Control('preflight').addEventListener('click', async () => { + ps5A8ControlPlan = null; + ps5A8Control('preflight').disabled = true; + ps5A8Control('install').disabled = true; + ps5A8Control('status').textContent = ps5NativePackageText('ps5NativePackages.a8Control.preflighting', 'Running a complete fresh console scan to prove CHIM00002 is absent…'); + try { + ps5A8ControlPlan = await window.pork.ps5A8DistinctControl('install-plan'); + ps5A8Control('status').textContent = ps5NativePackageText('ps5NativePackages.a8Control.preflightReady', 'Target absence proven. The single-use install approval expires in two minutes.'); + ps5A8Control('install').disabled = false; + } catch (error) { ps5A8Control('status').textContent = error.message; ps5A8Control('preflight').disabled = false; } +}); +ps5A8Control('install').addEventListener('click', async () => { + if (!ps5A8ControlPlan) return; + const plan = ps5A8ControlPlan; + const confirmation = await showExactConfirmation(`${ps5NativePackageText('ps5NativePackages.a8Control.confirm', 'Install the exact frozen metadata-qualified CHIM00002 control after the target-absent scan?')}\n\n${plan.artifactSha256}\n\n${ps5NativePackageText('ps5NativePackages.a8Control.boundary', 'This changes the PS5. Installer completion is not installation proof; a fresh post-scan and separate launch qualification remain mandatory.')}`, plan.confirmation); + if (!confirmation) return; + ps5A8Control('install').disabled = true; + ps5A8Control('status').textContent = ps5NativePackageText('ps5NativePackages.a8Control.installing', 'Starting the exact single-use CHIM00002 installation…'); + try { + const result = await window.pork.ps5A8DistinctControl('install', { planDigest: plan.planDigest, confirmation }); + ps5A8ControlPlan = null; + ps5A8ControlOperationId = result.operationId; + void monitorPs5ReferenceInstall(result.operationId, { active: () => ps5A8ControlOperationId, element: ps5A8Control, key: 'a8Control', label: 'CHIM00002 control', awaiting: 'Installer completed. Run the independent CHIM00002 post-scan.', dpiAwaiting: 'Standalone DPI accepted CHIM00002. Only the independent post-scan can prove installation.' }); + } catch (error) { ps5A8ControlPlan = null; ps5A8Control('status').textContent = error.message; ps5A8Control('preflight').disabled = false; } +}); +ps5A8Control('verify').addEventListener('click', async () => { + if (!ps5A8ControlOperationId) return; + ps5A8Control('verify').disabled = true; + ps5A8Control('status').textContent = ps5NativePackageText('ps5NativePackages.a8Control.scanning', 'Scanning the console for fresh CHIM00002 installation evidence…'); + try { + const result = await window.pork.ps5NativePackageRestoreVerify({ titleId: 'CHIM00002', operationId: ps5A8ControlOperationId }); + if (result.operation) renderPs5A8Control({ status: 'ready' }, result.operation); + if (!result.verified) { ps5A8Control('status').textContent = ps5NativePackageText('ps5NativePackages.a8Control.notObserved', 'CHIM00002 was not observed by the fresh console scan.'); ps5A8Control('verify').disabled = false; } + } catch (error) { ps5A8Control('status').textContent = error.message; ps5A8Control('verify').disabled = false; } +}); +ps5A8Control('launch').addEventListener('click', async () => { + if (!ps5A8ControlOperationId) return; + try { + const plan = await window.pork.ps5NativePackageRestoreLaunchPlan(ps5A8ControlOperationId); + const confirmation = await showExactConfirmation(`${ps5NativePackageText('ps5NativePackages.a8Control.launchConfirm', 'Launch exact title CHIM00002 and observe its process?')}\n\n${plan.packageIdentitySha256}\n\n${ps5NativePackageText('ps5NativePackages.a8Control.launchBoundary', 'The launch request alone is not success. Chimera requires a stable exact-title process and retains terminal failure evidence.')}`, plan.confirmation); + if (!confirmation) return; + ps5A8Control('launch').disabled = true; + ps5A8Control('status').textContent = ps5NativePackageText('ps5NativePackages.a8Control.launching', 'Requesting CHIM00002 and observing its exact process…'); + const operation = await window.pork.ps5NativePackageRestoreLaunch({ operationId: ps5A8ControlOperationId, confirmation }); + renderPs5A8Control({ status: 'ready' }, operation); + void monitorPs5A8ControlLaunch(operation.id); + } catch (error) { ps5A8Control('status').textContent = error.message; } +}); +$('ps5-a10-control-card').addEventListener('click', async event => { + const button = event.target.closest('button[id^="ps5-a10-control-"]'); if (!button || button.disabled) return; + const action = button.id.slice('ps5-a10-control-'.length); const status = ps5A10Control('status'); button.disabled = true; + try { + if (action === 'prepare') { ps5A10ControlPlan = null; status.textContent = ps5A10Text('preparing'); await window.pork.ps5A10TransitionControl('prepare'); } + else if (action === 'preflight') { ps5A10ControlPlan = null; status.textContent = ps5A10Text('preflighting'); ps5A10ControlPlan = await window.pork.ps5A10TransitionControl('install-plan'); status.textContent = ps5A10Text('preflightReady'); ps5A10Control('install').disabled = false; return; } + else if (action === 'install') { + const plan = ps5A10ControlPlan; if (!plan) return; const confirmation = await confirmPs5A10('confirm', 'boundary', plan.confirmation, plan.artifactSha256); if (!confirmation) { button.disabled = false; return; } + status.textContent = ps5A10Text('installing'); const result = await window.pork.ps5A10TransitionControl('install', { planDigest: plan.planDigest, confirmation }); + ps5A10ControlPlan = null; ps5A10ControlOperationId = result.operationId; + void monitorPs5ReferenceInstall(result.operationId, { active: () => ps5A10ControlOperationId, element: ps5A10Control, key: 'a10Control', label: 'CHIM00003', awaiting: ps5A10Text('awaitingScan'), dpiAwaiting: ps5A10Text('dpiAwaitingScan') }); return; + } else if (action === 'verify') { status.textContent = ps5A10Text('scanning'); const result = await window.pork.ps5NativePackageRestoreVerify({ titleId: 'CHIM00003', operationId: ps5A10ControlOperationId }); if (!result.verified) { status.textContent = ps5A10Text('notObserved'); button.disabled = false; return; } } + else if (action === 'stage') { const plan = await window.pork.ps5P15A10('runtime-plan'); const confirmation = await confirmPs5A10('stageConfirm', 'stageBoundary', plan.confirmation, plan.diagnosticEngineSha256); if (!confirmation) { status.textContent = ps5A10Text('planExpires'); return; } await window.pork.ps5P15A10('runtime-stage', { planDigest: plan.planDigest, confirmation }); } + else if (action === 'payload') { const confirmation = await confirmPs5A10('payloadConfirm', 'payloadBoundary', 'START PS5 A10 PROFILE PAYLOAD'); if (!confirmation) { button.disabled = false; return; } await window.pork.ps5P15A10('payload-start', { confirmation }); } + else if (action === 'launch') { + const plan = await window.pork.ps5NativePackageRestoreLaunchPlan(ps5A10ControlOperationId); const confirmation = await confirmPs5A10('launchConfirm', 'launchBoundary', plan.confirmation, plan.packageIdentitySha256); if (!confirmation) { button.disabled = false; return; } + status.textContent = ps5A10Text('launching'); const operation = await window.pork.ps5NativePackageRestoreLaunch({ operationId: ps5A10ControlOperationId, confirmation }); void monitorPs5A10ControlLaunch(operation.id); return; + } else if (action === 'restore') { const confirmation = await confirmPs5A10('restoreConfirm', 'restoreBoundary', 'RESTORE PS5 PRODUCTION PROFILE RUNTIME'); if (!confirmation) { button.disabled = false; return; } await window.pork.ps5P15A10('runtime-restore', { confirmation }); } + else if (action === 'evidence') { const e = await window.pork.ps5NativePackageRetainedLaunchEvidence(ps5A10ControlOperationId), a = e.analysis || {}, s = $('ps5-a10-control-evidence-summary'), b = a.firstUnprovenBoundary === 'after-accepted-shellui-lnc-before-stable-exact-title-process' ? ps5A10Text('evidencePostLnc') : a.stableExactTitleProcess ? ps5A10Text('evidenceVerified') : ps5A10Text('evidenceInconclusive'); s.hidden = false; s.dataset.operationId = String(e.operation.id); s.textContent = `${b} · ${ps5A10Text('evidenceExactProcesses')}: ${Number(a.exactTitleProcessObservationCount || 0)} · ${e.retention?.rawSourceComplete ? ps5A10Text('evidenceCaptureComplete') : ps5A10Text('evidenceCapturePartial')} · SHA-256 ${String(e.integrity?.retainedLaunchEvidenceSha256 || '').slice(0, 12)}… · ${ps5A10Text('evidenceDownloaded')}`; (window.pork.download || window.xavatarAPI?.download)(new TextEncoder().encode(JSON.stringify(e, null, 2)), `chimera-ps5-launch-evidence-${e.operation.titleId}-${e.operation.id}.json`); button.disabled = false; return; } + await refreshPs5NativePackageWorkspace(); + } catch (error) { status.textContent = error.message; button.disabled = false; } +}); +$('ps5-p15-r2-preflight').addEventListener('click', async () => { + const button = $('ps5-p15-r2-preflight'); button.disabled = true; + $('ps5-p15-r2-state').textContent = ps5NativePackageText('ps5NativePackages.r4.collecting', 'Collecting fresh read-only hardware evidence…'); + try { + ps5P15R2LastPreflight = await window.pork.ps5P15R2Preflight(); + renderPs5P15R2Qualification(await window.pork.ps5P15R2Status(), ps5P15R2LastPreflight); + } catch (error) { $('ps5-p15-r2-state').textContent = error.message; } + finally { button.disabled = false; } +}); +$('ps5-p15-r2-qualify').addEventListener('click', async () => { + if (ps5P15R2LastPreflight?.allGatesPass !== true || ps5P15R2QualificationAttempted) return; + ps5P15R2QualificationAttempted = true; + $('ps5-p15-r2-qualify').disabled = true; + $('ps5-p15-r2-state').textContent = ps5NativePackageText('ps5NativePackages.r4.qualifying', 'Recollecting every gate for the single qualification…'); + try { + const status = await window.pork.ps5P15R2Qualify(); + const before = ps5P15R2LastPreflight.matrix.map(row => `${row.gate}:${row.result}`).join('|'); + const after = (status.lastQualification?.matrix || []).map(row => `${row.gate}:${row.result}`).join('|'); + if (!status.profile || status.readiness?.launchEligible !== true || before !== after) throw new Error(ps5NativePackageText('ps5NativePackages.r4.drift', 'Qualification failed closed because independently recollected evidence drifted.')); + renderPs5P15R2Qualification(status, status.lastQualification); + } catch (error) { $('ps5-p15-r2-state').textContent = error.message; } +}); +window.addEventListener('chimera:language-changed', () => { const e = $('ps5-a10-control-evidence-summary'); e.hidden = true; e.textContent = ''; }); +ps5LaunchReference('import').addEventListener('click', async () => { + ps5LaunchReference('import').disabled = true; ps5LaunchReference('status').textContent = ps5NativePackageText('ps5NativePackages.launchReference.importing', 'Downloading and verifying the pinned websrv launcher…'); + try { renderPs5LaunchReference(await window.pork.ps5LaunchReference('import')); } + catch (error) { ps5LaunchReference('status').textContent = error.message; ps5LaunchReference('import').disabled = false; } +}); +ps5LaunchReference('restore').addEventListener('click', async () => { + const confirmation = await showExactConfirmation(`${ps5NativePackageText('ps5NativePackages.launchReference.confirm', 'Install the exact hash-pinned FAKE00000 websrv launch reference?')}\n\n${ps5NativePackageText('ps5NativePackages.launchReference.boundary', 'This changes the PS5. A post-scan proves installation only; you must launch it manually to prove execution.')}`, 'INSTALL PS5 LAUNCH REFERENCE'); + if (!confirmation) return; + ps5LaunchReference('restore').disabled = true; ps5LaunchReference('status').textContent = ps5NativePackageText('ps5NativePackages.launchReference.starting', 'Starting the guarded launch-reference installation…'); + try { const result = await window.pork.ps5LaunchReference('restore', { confirmation }); ps5LaunchReferenceOperationId = result.operationId; void monitorPs5ReferenceInstall(result.operationId, { active: () => ps5LaunchReferenceOperationId, element: ps5LaunchReference, key: 'launchReference', label: 'reference', awaiting: 'Installer completed. Run the post-scan.' }); } + catch (error) { ps5LaunchReference('status').textContent = error.message; ps5LaunchReference('restore').disabled = false; } +}); +ps5LaunchReference('verify').addEventListener('click', async () => { + if (!ps5LaunchReferenceOperationId) return; + ps5LaunchReference('verify').disabled = true; ps5LaunchReference('status').textContent = ps5NativePackageText('ps5NativePackages.launchReference.scanning', 'Scanning the console for fresh FAKE00000 evidence…'); + try { const result = await window.pork.ps5LaunchReference('restore-verify', { operationId: ps5LaunchReferenceOperationId }); ps5LaunchReference('status').textContent = result.verified ? ps5NativePackageText('ps5NativePackages.launchReference.manualLaunch', 'Installation proven. Launch Homebrew Launcher manually on the PS5; launch is not yet proven.') : ps5NativePackageText('ps5NativePackages.launchReference.notObserved', 'The launch reference was not observed by the fresh console scan.'); ps5LaunchReference('trust').textContent = result.verified ? ps5NativePackageText('ps5NativePackages.launchReference.launchRequired', 'Installed · launch required') : ps5NativePackageText('ps5NativePackages.launchReference.unproven', 'Unproven'); ps5LaunchReference('trust').dataset.trust = result.verified ? 'post-scan-proven' : 'unproven'; if (!result.verified) ps5LaunchReference('verify').disabled = false; } + catch (error) { ps5LaunchReference('status').textContent = error.message; ps5LaunchReference('verify').disabled = false; } +}); +$('ps5-staging-discover').addEventListener('click', async () => { + const game = state.currentGame; $('ps5-staging-discover').disabled = true; setPs5NativePackageReadiness(ps5NativePackageText('ps5NativePackages.stage.discovering', 'Discovering exact title-bound console mounts…')); + try { + ps5ConsoleDiscovery = await window.pork.ps5ConsoleStagingDiscover(game.game_id); + $('ps5-staging-components').innerHTML = ps5ConsoleDiscovery.components.length ? ps5ConsoleDiscovery.components.map(item => ``).join('') : `

    ${escHtml(ps5NativePackageText('ps5NativePackages.stage.none', 'No exact app0 or patch0 mount was found for this title.'))}

    `; + $('ps5-staging-run').disabled = !ps5ConsoleDiscovery.components.length; + setPs5NativePackageReadiness(ps5ConsoleDiscovery.components.length ? ps5NativePackageText('ps5NativePackages.stage.discovered', 'Console components found. Review the selection before staging.') : ps5NativePackageText('ps5NativePackages.stage.none', 'No exact app0 or patch0 mount was found for this title.'), ps5ConsoleDiscovery.components.length ? 'ready' : 'blocked'); + } catch (error) { setPs5NativePackageReadiness(error.message, 'blocked'); } + finally { $('ps5-staging-discover').disabled = false; } +}); +$('ps5-staging-run').addEventListener('click', async () => { + if (!ps5ConsoleDiscovery) return; + const componentIds = [...document.querySelectorAll('[data-ps5-stage-component]:checked')].map(item => item.dataset.ps5StageComponent); + $('ps5-staging-run').disabled = true; setPs5NativePackageReadiness(ps5NativePackageText('ps5NativePackages.stage.running', 'Inventorying, transferring and hashing every selected file…')); + try { + const created = await window.pork.ps5ConsoleStagingCreate({ discoveryId: ps5ConsoleDiscovery.id, componentIds }); + const completed = await window.pork.ps5ConsoleStagingRun(created.id); + ps5PreparedSources = ps5SourcesFromStagedJob(completed); + const base = ps5PreparedSources?.find(item => item.componentType === 'base'); + if (base) $('ps5-native-package-source').value = base.sourcePath; + setPs5NativePackageReadiness(ps5NativePackageText('ps5NativePackages.stage.completed', 'Console copy staged atomically with complete per-file hash evidence.'), 'ready'); + await refreshPs5NativePackageWorkspace(); + } catch (error) { setPs5NativePackageReadiness(error.message, 'blocked'); $('ps5-staging-run').disabled = false; } +}); +$('ps5-native-package-create').addEventListener('click', async () => { + const game = state.currentGame; $('ps5-native-package-create').disabled = true; setPs5NativePackageReadiness(ps5NativePackageText('ps5NativePackages.create.running', 'Verifying decrypted modules and building a native FIH package…')); + try { const input = ps5PreparedSources?.length ? { sources: ps5PreparedSources, expectedTitleId: game.game_id } : { sourcePath: $('ps5-native-package-source').value, expectedTitleId: game.game_id }; const job = await window.pork.ps5NativePackageCreate(input); await window.pork.ps5NativePackageRun(job.id); void monitorPs5NativePackageBuild(job.id); await refreshPs5NativePackageWorkspace(); setPs5NativePackageReadiness(ps5NativePackageText('ps5NativePackages.create.queued', 'Build accepted. It continues on the server if this browser session closes.'), 'running'); } + catch (error) { setPs5NativePackageReadiness(error.message, 'blocked'); } + finally { $('ps5-native-package-create').disabled = !$('ps5-native-package-source').value; } +}); +$('ps5-native-package-restore').addEventListener('click', async () => { + const game = state.currentGame; const packageSetId = Number($('ps5-native-package-set').value); if (!packageSetId) return; + try { const plan = await window.pork.ps5NativePackageRestorePlan({ titleId: game.id, packageSetId }); const detail = plan.orderedComponents.map(item => `${item.type.toUpperCase()} · ${item.version || '—'} · ${item.sha256.slice(0, 12)}…`).join('\n'); const confirmation = await showExactConfirmation(`${ps5NativePackageText('ps5NativePackages.restore.confirm', 'Install this verified PS5 debug .pkg set?')}\n\n${detail}\n\n${ps5NativePackageText('ps5NativePackages.restore.boundary', 'PS5Upload will install each .pkg in order. Completion still requires an independent console scan.')}`, 'INSTALL PS5 DEBUG PACKAGE'); if (!confirmation) return; setPs5NativePackageReadiness(ps5NativePackageText('ps5NativePackages.restore.running', 'Starting the hash-verified PS5 .pkg installation…'), 'running'); const result = await window.pork.ps5NativePackageRestore({ titleId: game.id, packageSetId, confirmation }); ps5NativeInstallOperationId = result.operationId; $('ps5-native-package-cancel').disabled = true; void monitorPs5NativePackageInstall(result.operationId); } + catch (error) { setPs5NativePackageReadiness(error.message, 'blocked'); } +}); +$('ps5-native-package-cancel').addEventListener('click', async () => { if (!ps5NativeInstallOperationId) return; try { const confirmation = await showExactConfirmation(ps5NativePackageText('ps5NativePackages.restore.cancelConfirm', 'Cancel the active PS5 .pkg installation? The server copy remains intact.'), 'CANCEL PS5 PACKAGE INSTALL'); if (!confirmation) return; await window.pork.ps5NativePackageRestoreCancel(ps5NativeInstallOperationId, confirmation); ps5NativeInstallOperationId = null; $('ps5-native-package-cancel').disabled = true; setPs5NativePackageReadiness(ps5NativePackageText('ps5NativePackages.restore.cancelled', 'PS5 .pkg installation cancelled. Source packages were kept.'), 'blocked'); } catch (error) { setPs5NativePackageReadiness(error.message, 'blocked'); } }); +$('ps5-native-package-verify').addEventListener('click', async () => { const game = state.currentGame; setPs5NativePackageReadiness(ps5NativePackageText('ps5NativePackages.verify.running', 'Scanning the console for independent installation evidence…')); try { const result = await window.pork.ps5NativePackageRestoreVerify({ titleId: game.game_id, operationId: ps5NativeInstallOperationId }); if (result.operation) renderPs5RestoreQualification(result.operation); setPs5NativePackageReadiness(result.verified ? ps5NativePackageText('ps5NativePackages.verify.completedPendingLaunch', 'Installation proven. Launch verification is still required.') : ps5NativePackageText('ps5NativePackages.verify.notObserved', 'The console scan has not observed this installation.'), result.verified ? 'ready' : 'blocked'); } catch (error) { setPs5NativePackageReadiness(error.message, 'blocked'); } }); +async function monitorPs5NativePackageLaunch(operationId) { + while (ps5NativeInstallOperationId === operationId) { + await new Promise(resolve => setTimeout(resolve, 2000)); + let operation; + try { operation = await window.pork.ps5NativePackageRestoreLaunchStatus(operationId); } + catch (error) { setPs5NativePackageReadiness(error.message, 'blocked'); return; } + renderPs5RestoreQualification(operation); + if (operation.status === 'launch_verified') { setPs5NativePackageReadiness(ps5NativePackageText('ps5NativePackages.launch.qualified', 'Installed · Launch verified · Qualified for restore'), 'ready'); return; } + if (operation.status === 'launch_failed') { setPs5NativePackageReadiness(`${ps5NativePackageText('ps5NativePackages.launch.failedBoundary', 'Installed — launch failed. Not qualified for restore.')} · ${operation.error?.code || ''}`, 'blocked'); return; } + if (operation.status !== 'launching') { setPs5NativePackageReadiness(operation.error?.message || operation.status, 'blocked'); return; } + } +} +$('ps5-native-package-launch').addEventListener('click', async () => { + if (!ps5NativeInstallOperationId) return; + try { + const plan = await window.pork.ps5NativePackageRestoreLaunchPlan(ps5NativeInstallOperationId); + const confirmation = await showExactConfirmation(`${ps5NativePackageText('ps5NativePackages.launch.confirm', 'Launch this exact installed title and observe its process?')}\n\n${plan.titleId}\n${plan.packageIdentitySha256}\n\n${ps5NativePackageText('ps5NativePackages.launch.boundary', 'A launch request alone is not success. Chimera requires a stable exact-title process and stores failure evidence.')}`, plan.confirmation); + if (!confirmation) return; + $('ps5-native-package-launch').disabled = true; + setPs5NativePackageReadiness(ps5NativePackageText('ps5NativePackages.launch.starting', 'Requesting the exact title launch; qualification is still pending…'), 'running'); + const operation = await window.pork.ps5NativePackageRestoreLaunch({ operationId: ps5NativeInstallOperationId, confirmation }); + renderPs5RestoreQualification(operation); + void monitorPs5NativePackageLaunch(operation.id); + } catch (error) { setPs5NativePackageReadiness(error.message, 'blocked'); } +}); +$('ps5-native-package-jobs').addEventListener('click', async event => { const packageButton = event.target.closest('[data-ps5-native-resume]'); const stageButton = event.target.closest('[data-ps5-stage-resume]'); if (!packageButton && !stageButton) return; try { if (stageButton) { await window.pork.ps5ConsoleStagingResume(stageButton.dataset.ps5StageResume); await window.pork.ps5ConsoleStagingRun(stageButton.dataset.ps5StageResume); } else { await window.pork.ps5NativePackageResume(packageButton.dataset.ps5NativeResume); await window.pork.ps5NativePackageRun(packageButton.dataset.ps5NativeResume); void monitorPs5NativePackageBuild(packageButton.dataset.ps5NativeResume); } await refreshPs5NativePackageWorkspace(); } catch (error) { setPs5NativePackageReadiness(error.message, 'blocked'); } }); + +$('btn-modal-backup').addEventListener('click', async () => { + if (!state.currentGame || !state.connected) return; + const { ftp_path, game_id } = state.currentGame; + if (!ftp_path) { setStatus('No FTP path recorded for this game — run a scan first', 'error'); return; } + try { + await window.pork.ftpDownloadFolder(game_id); + setStatus('Backup queued; follow its progress in Transfers.', 'ok'); + closeModal(); + } catch (e) { + setStatus(e.message, 'error'); + } +}); + +$('btn-modal-install').addEventListener('click', async () => { + if (!state.currentGame || !state.connected) return; + const g = state.currentGame; + const ftpPath = await ensureGameFtpPath(g.game_id); + if (!ftpPath) return; // user cancelled path selection + + let backups; + try { backups = await window.pork.listBackups(g.game_id); } catch { backups = []; } + if (!backups.length) { setStatus('No local backups found — add one via the Manage tab', 'error'); return; } + + const latest = backups.sort((a, b) => (b.id || 0) - (a.id || 0))[0]; + try { + await window.pork.ftpUpload({ localPath: latest.backup_path, remotePath: ftpPath }); + setStatus('Install queued — see Transfers for progress', 'ok'); + closeModal(); + } catch (e) { + setStatus(`Install failed: ${e.message}`, 'error'); + } +}); + +$('btn-modal-refresh').addEventListener('click', async () => { + if (!state.currentGame) return; + const { game_id } = state.currentGame; + $('btn-modal-refresh').disabled = true; + setStatus('Refreshing metadata…'); + try { + await window.pork.prosperoFetch(game_id); + setStatus('Metadata refreshed', 'ok'); + } catch (e) { + setStatus(`Refresh failed: ${e.message}`, 'error'); + } finally { + $('btn-modal-refresh').disabled = false; + } +}); + +function closeModal() { + if (typeof closeAccessibleDialog === 'function') closeAccessibleDialog($('modal-overlay')); + else $('modal-overlay').hidden = true; + state.currentGame = null; + $('modal-banner').style.backgroundImage = ''; + const icon = $('modal-icon'); + icon.src = ''; icon.hidden = true; + $('modal-description').hidden = true; + $('modal-description').textContent = ''; + $('modal-hash-section').hidden = true; + $('modal-hash-progress').hidden = true; + $('hash-progress-fill').style.width = '0%'; + uploadLocalPath = null; + uploadInProgress = false; + porkInProgress = false; + $('manage-remote-path').value = ''; + $('manage-remote-path').readOnly = true; + $('btn-manage-upload').disabled = true; + $('manage-upload-log').hidden = true; + $('mtab-manage-count').textContent = ''; + $('manage-backpork-section').hidden = true; + $('manage-pork-progress').hidden = true; + $('pork-progress-fill').style.width = '0%'; +} + +$('modal-close').addEventListener('click', closeModal); +$('btn-modal-close2').addEventListener('click', closeModal); +$('modal-overlay').addEventListener('click', e => { if (e.target === $('modal-overlay')) closeModal(); }); +$('modal-workspace-links')?.addEventListener('click', event => { + const button = event.target.closest('[data-game-route][data-game-target]'); + const gameId = state.currentGame?.game_id; + if (!button || !gameId || !setGameWorkspaceContext(gameId, button.dataset.gameTarget)) return; + closeModal(); + window.chimeraNavigate?.(button.dataset.gameRoute); +}); +document.addEventListener('keydown', e => { + if (e.key === 'Escape' && $('modal-overlay') && !$('modal-overlay').hidden) closeModal(); +}); + diff --git a/renderer/modules/navigation-routes.js b/renderer/modules/navigation-routes.js new file mode 100644 index 0000000..b0b8195 --- /dev/null +++ b/renderer/modules/navigation-routes.js @@ -0,0 +1,110 @@ +'use strict'; + +(function exposeNavigationContract(root, factory) { + const contract = factory(); + if (typeof module === 'object' && module.exports) module.exports = contract; + else root.ChimeraNavigationContract = contract; +})(typeof globalThis !== 'undefined' ? globalThis : this, function buildNavigationContract() { + const groups = [ + { + id: 'home', label: 'Home', icon: 'home', defaultRoute: 'home', + routes: [ + { id: 'home', label: 'Overview', description: 'Library, connection, activity and quick actions.', page: 'dashboard', keywords: ['dashboard', 'start'] }, + ], + }, + { + id: 'library', label: 'Library', icon: 'library', defaultRoute: 'library/games', + routes: [ + { id: 'library/games', label: 'Games', description: 'Complete game library with metadata and cover art.', page: 'games', keywords: ['titles', 'roms', 'covers'] }, + { id: 'library/updates', label: 'Game updates', description: 'Available patches and updates for each title.', page: 'ecosystem', tab: 'game-updates', keywords: ['patches'] }, + { id: 'library/cheats', label: 'Cheats & patches', description: 'Search, review and manage cheats and patches.', page: 'ecosystem', tab: 'cheats', keywords: ['goldhen', 'patches'] }, + { id: 'library/media', label: 'Media', description: 'Screenshots and video clips from the console.', page: 'media', keywords: ['screenshots', 'video'] }, + { id: 'library/mounts', label: 'Mounts', description: 'ShadowMount configuration with restore-point protection.', page: 'operations', panel: 'shadowmount', keywords: ['shadowmount', 'folders'], expert: true }, + { id: 'library/packages', label: 'Packages', description: 'Browse package sets, components, versions, hashes and recipes.', page: 'packages', keywords: ['pkg', 'sets', 'components', 'recipes'] }, + ], + }, + { + id: 'console', label: 'Console', icon: 'console', defaultRoute: 'console/status', + routes: [ + { id: 'console/status', label: 'Status', description: 'Configured PS5 endpoint, connection evidence and service health.', page: 'ecosystem', tab: 'overview', keywords: ['health', 'overview'] }, + { id: 'console/live', label: 'Live session', description: 'Live console information and the active session.', page: 'ecosystem', tab: 'live', keywords: ['runtime', 'temperature'], expert: true }, + { id: 'console/remote-play', label: 'Remote Play', description: 'Discover and wake the PS5, then start a local chiaki-ng session.', page: 'ecosystem', tab: 'remote-play', keywords: ['chiaki', 'stream', 'controller', 'wake'] }, + { id: 'console/system-view', label: 'System View', description: 'Capture-card video, audio and recording.', page: 'system-view', keywords: ['capture', 'stream'] }, + { id: 'console/files', label: 'Files', description: 'Safely browse files on the PS5.', page: 'ecosystem', tab: 'files', keywords: ['ftp', 'browser'], expert: true }, + { id: 'console/profile', label: 'Profile & compatibility', description: 'Firmware, HEN and loader profile with validation.', page: 'operations', panel: 'compatibility', keywords: ['firmware', 'etahen', 'kstuff'] }, + { id: 'console/etahen', label: 'etaHEN', description: 'etaHEN configuration and integration status.', page: 'ecosystem', tab: 'etahen', keywords: ['hen'] }, + { id: 'console/notifications', label: 'Notifications', description: 'Test the connection and send console notifications.', page: 'psnotify', keywords: ['notify', 'toast'] }, + { id: 'console/avatar', label: 'XAvatar', description: 'Convert, transfer and manage avatars.', page: 'xavatar', keywords: ['profile', 'image'] }, + { id: 'console/power', label: 'System actions', description: 'Confirmed power, rest-mode and disc actions.', page: 'jailbreak', focus: 'power', keywords: ['shutdown', 'reboot', 'eject'], risk: 'confirm', expert: true }, + ], + }, + { + id: 'payloads', label: 'Payloads & Homebrew', icon: 'payload', defaultRoute: 'payloads/installed', + routes: [ + { id: 'payloads/installed', label: 'Payload Manager', description: 'PS5 manager, local payloads, catalog and sender.', page: 'jailbreak', focus: 'payloads', keywords: ['sender', 'elf', 'bin'] }, + { id: 'payloads/updates', label: 'Updates', description: 'Safely update payloads and components with version checks.', page: 'ecosystem', tab: 'updates', keywords: ['upgrade', 'watchdog'] }, + { id: 'payloads/catalog', label: 'Component catalog', description: 'Tracked PS5 projects, releases and compatibility.', page: 'ecosystem', tab: 'components', keywords: ['projects', 'releases'] }, + { id: 'payloads/homebrew', label: 'Homebrew', description: 'Homebrew library and available releases.', page: 'ecosystem', tab: 'homebrew', keywords: ['apps'] }, + { id: 'payloads/plugins', label: 'Plugins', description: 'Plugin library and source information.', page: 'ecosystem', tab: 'plugins', keywords: ['extensions'] }, + { id: 'payloads/boot', label: 'Boot & autoload', description: 'Autoload order, snapshots and USB/disc builders.', page: 'jailbreak', focus: 'boot', keywords: ['y2jb', 'bdjb', 'autoload'] }, + { id: 'payloads/elf-arsenal', label: 'ELF Arsenal', description: 'Manage ELF files and send them to the console.', page: 'elf-arsenal', keywords: ['payload', 'sender'] }, + ], + }, + { + id: 'protection', label: 'Protection', icon: 'shield', defaultRoute: 'protection/backups', + routes: [ + { id: 'protection/backups', label: 'Backups', description: 'View and manage game backups.', page: 'backups', keywords: ['backup'] }, + { id: 'protection/trophies', label: 'Trophy evidence', description: 'Compare read-only PSN proof with the selected local profile.', page: 'trophies', keywords: ['trophy', 'profile', 'evidence', 'reconstruct'] }, + { id: 'protection/images', label: 'Backpork images', description: 'Manage, build and verify firmware images.', page: 'backporks', keywords: ['image', 'firmware'] }, + { id: 'protection/saves', label: 'Save Vault', description: 'Save snapshots, comparison and safe recovery.', page: 'ecosystem', tab: 'saves', keywords: ['saves', 'restore'] }, + { id: 'protection/save-service', label: 'GarlicSaves worker', description: 'Install and manage the optional GarlicSaves community worker.', page: 'garlic', keywords: ['garlic', 'community', 'worker'], expert: true }, + { id: 'protection/save-browser', label: 'Save browser', description: 'Advanced local save browser.', page: 'savemgr', keywords: ['local save manager'], expert: true }, + { id: 'protection/recovery', label: 'Recovery Vault', description: 'Create, export and restore recovery points.', page: 'operations', panel: 'recovery', keywords: ['snapshot', 'restore'], risk: 'confirm' }, + { id: 'protection/integrity', label: 'Integrity audit', description: 'Audit mounts, registrations and orphaned files read-only.', page: 'operations', panel: 'storage', keywords: ['storage', 'audit'] }, + { id: 'protection/audit', label: 'Audit Center', description: 'Search mutating actions, results and safety classifications.', page: 'operations', panel: 'audit', keywords: ['log', 'history', 'security'], expert: true }, + { id: 'protection/replication', label: 'Replication', description: 'Use a second backup target with SHA-256 verification.', page: 'operations', panel: 'reliability', focus: 'replication', keywords: ['3-2-1', 'copy'], expert: true }, + ], + }, + { + id: 'tools', label: 'Tools', icon: 'tools', defaultRoute: 'tools/transfers', + routes: [ + { id: 'tools/transfers', label: 'Transfers', description: 'Queue, progress and recent FTP transfers.', page: 'transfers', keywords: ['upload', 'download'], badge: 'transfer' }, + { id: 'tools/pfs', label: 'PFS Ripper', description: 'PFS workflow and extraction tools.', page: 'pfs-ripper', keywords: ['extract'], expert: true }, + { id: 'tools/database', label: 'Database', description: 'Manage the Chimera database and hash information.', page: 'database', keywords: ['sqlite', 'hash'], expert: true }, + { id: 'tools/itemzflow', label: 'Itemzflow', description: 'Itemzflow status and integration.', page: 'ecosystem', tab: 'itemzflow', keywords: ['itemz'], expert: true }, + { id: 'tools/timeline', label: 'Timeline', description: 'Recent platform and console activity.', page: 'ecosystem', tab: 'timeline', keywords: ['events', 'log'], expert: true }, + { id: 'tools/diagnostics', label: 'Diagnostics', description: 'Read-only probes and a sanitized support bundle.', page: 'operations', panel: 'diagnostics', keywords: ['support', 'logs'], expert: true }, + { id: 'tools/package-services', label: 'Package Services', description: 'Expert DPI, Void and raw package service diagnostics.', page: 'operations', panel: 'packages', keywords: ['dpi', 'voidpkg', 'handoff'], expert: true }, + { id: 'tools/research', label: 'Research', description: 'Evaluated projects, sources and expert information.', page: 'ecosystem', tab: 'research', keywords: ['linux', 'sources'], expert: true }, + ], + }, + { + id: 'settings', label: 'Settings', icon: 'settings', defaultRoute: 'settings/general', + routes: [ + { id: 'settings/general', label: 'General', description: 'Language, paths, console connection and platform preferences.', page: 'settings', keywords: ['configuration', 'ftp'] }, + { id: 'settings/automation', label: 'Automation', description: 'Safe maintenance tasks and execution history.', page: 'operations', panel: 'automation', keywords: ['scheduler', 'tasks'] }, + { id: 'settings/sources', label: 'Sources & trust', description: 'Upstream sources and supply-chain policy.', page: 'ecosystem', tab: 'sources', keywords: ['https', 'checksum'] }, + { id: 'settings/security', label: 'Security', description: 'Active sessions and password management.', page: 'operations', panel: 'reliability', focus: 'security', keywords: ['sessions', 'password'] }, + { id: 'settings/monitoring', label: 'Monitoring', description: 'Health metrics and Prometheus token.', page: 'operations', panel: 'reliability', focus: 'monitoring', keywords: ['metrics', 'prometheus'], expert: true }, + ], + }, + ]; + + const legacyAliases = { + dashboard: 'home', games: 'library/games', media: 'library/media', cheats: 'library/cheats', + backporks: 'protection/images', backups: 'protection/backups', trophies: 'protection/trophies', garlic: 'protection/save-service', + savemgr: 'protection/save-browser', jailbreak: 'payloads/installed', psnotify: 'console/notifications', + xavatar: 'console/avatar', 'system-view': 'console/system-view', + transfers: 'tools/transfers', database: 'tools/database', 'elf-arsenal': 'payloads/elf-arsenal', + 'pfs-ripper': 'tools/pfs', ecosystem: 'console/status', operations: 'settings/automation', settings: 'settings/general', + }; + + const routes = groups.flatMap(group => group.routes.map(route => Object.freeze({ ...route, group: group.id }))); + return Object.freeze({ + version: 1, + defaultRoute: 'home', + groups: Object.freeze(groups.map(group => Object.freeze({ ...group, routes: Object.freeze(group.routes) }))), + routes: Object.freeze(routes), + legacyAliases: Object.freeze(legacyAliases), + }); +}); diff --git a/renderer/modules/navigation.js b/renderer/modules/navigation.js new file mode 100644 index 0000000..9fcef4f --- /dev/null +++ b/renderer/modules/navigation.js @@ -0,0 +1,1071 @@ +'use strict'; + +(function initChimeraNavigation() { + const contract = window.ChimeraNavigationContract; + if (!contract) { + console.error('[navigation] ChimeraNavigationContract is missing.'); + return; + } + + const routeById = new Map(contract.routes.map(route => [route.id, route])); + const groupById = new Map(contract.groups.map(group => [group.id, group])); + const expandedGroups = new Set(); + const iconPaths = { + home: '', + library: '', + console: '', + payload: '', + shield: '', + tools: '', + settings: '', + action: '', + more: '', + }; + + let currentRouteId = null; + let activePayloadStage = 'discover'; + let navigationSequence = 0; + let commandGameCache = null; + let commandGameCacheAt = 0; + let commandSearchSequence = 0; + let currentTransferState = { jobs: [], activeCount: 0, queuedCount: 0, paused: false }; + let currentActionCount = 0; + let expertMode = false; + + const tr = (key, fallback) => typeof window.t === 'function' ? window.t(key, fallback) : fallback; + const routeKey = route => `navigation.routes.${route.id.replace(/\//g, '.')}`; + const routeLabel = route => tr(`${routeKey(route)}.label`, route.label); + const routeDescription = route => tr(`${routeKey(route)}.description`, route.description); + const groupLabel = group => tr(`navigation.groups.${group.id}`, group.label); + const activeLocale = () => window.i18n?.current === 'nl' ? 'nl-BE' : 'en-GB'; + + const escapeText = value => String(value ?? '') + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + + function icon(name) { + return ``; + } + + function normalizeRoute(value) { + let routeId = String(value || '').trim().replace(/^#\/?/, '').replace(/^\/+|\/+$/g, ''); + try { routeId = decodeURIComponent(routeId); } catch (_) {} + routeId = contract.legacyAliases[routeId] || routeId; + if (!routeById.has(routeId)) return contract.defaultRoute; + const route = routeById.get(routeId); + if (route.expert && !expertMode) return groupById.get(route.group)?.defaultRoute || contract.defaultRoute; + return routeId; + } + + async function syncExpertPermission() { + if (typeof window.pork?.sessionExpertMode !== 'function') return expertMode; + try { + const result = await window.pork.sessionExpertMode(expertMode, expertMode ? 'ENABLE EXPERT MODE' : undefined); + expertMode = result?.enabled === true; + } catch (_) { + expertMode = false; + } + document.body.classList.toggle('chimera-expert-mode', expertMode); + const toggle = document.getElementById('chimera-expert-mode'); + if (toggle) toggle.checked = expertMode; + return expertMode; + } + + setTimeout(syncExpertPermission); + + function routeFromLocation() { + return normalizeRoute(window.location.hash); + } + + function routeUrl(routeId) { + return `#/${encodeURI(routeId)}`; + } + + function mountSidebar() { + const navList = document.querySelector('#sidebar .nav-list'); + if (!navList) return; + navList.className = 'nav-list chimera-nav-root'; + navList.setAttribute('aria-label', 'Chimera main navigation'); + navList.innerHTML = contract.groups.map(group => { + const isHome = group.routes.length === 1 && group.routes[0].id === 'home'; + const routeLinks = group.routes.map(route => ` +
  • + + ${escapeText(routeLabel(route))} + ${route.risk === 'confirm' ? `!` : ''} + ${route.badge === 'transfer' ? '' : ''} + +
  • `).join(''); + return ` +
  • + ${isHome ? ` + + ${icon(group.icon)}${escapeText(groupLabel(group))} + ` : ` + + `} +
  • `; + }).join('') + `
  • `; + + document.body.classList.toggle('chimera-expert-mode', expertMode); + + const aboutLabel = document.querySelector('#sidebar-donate .donate-label'); + if (aboutLabel) { + aboutLabel.removeAttribute('data-i18n'); + aboutLabel.textContent = tr('navigation.about', 'About Chimera'); + aboutLabel.dataset.i18n = 'navigation.about'; + } + } + + function mountShell() { + document.body.classList.add('chimera-shell-v2'); + const content = document.getElementById('content'); + if (!content) return; + + const toolbar = document.createElement('div'); + toolbar.id = 'chimera-shell-toolbar'; + toolbar.innerHTML = ` +
    + + + Console + Disconnected + +
    + + + `; + const mobileToggle = document.getElementById('mobile-nav-toggle'); + if (mobileToggle) toolbar.prepend(mobileToggle); + + const header = document.createElement('header'); + header.id = 'chimera-route-header'; + header.innerHTML = ` +
    + +

    Chimera

    +

    +
    +
    + + + +
    `; + content.prepend(header); + content.prepend(toolbar); + + const palette = document.createElement('div'); + palette.id = 'chimera-command-overlay'; + palette.className = 'chimera-overlay'; + palette.hidden = true; + palette.innerHTML = ` + `; + document.body.appendChild(palette); + + const drawer = document.createElement('div'); + drawer.id = 'chimera-jobs-overlay'; + drawer.className = 'chimera-overlay chimera-drawer-overlay'; + drawer.hidden = true; + drawer.innerHTML = ` + `; + document.body.appendChild(drawer); + + } + + function mountCompanionDock() { + if (document.getElementById('chimera-companion-dock')) return; + const dock = document.createElement('nav'); + dock.id = 'chimera-companion-dock'; + dock.className = 'chimera-companion-dock'; + dock.setAttribute('aria-label', tr('navigation.companion.label', 'Companion shortcuts')); + dock.dataset.i18nAriaLabel = 'navigation.companion.label'; + dock.innerHTML = ` + + ${icon('home')}Home + + + ${icon('library')}Games + + + ${icon('console')}Console + + + `; + document.body.appendChild(dock); + } + + function mountHomeCommandCenter() { + const page = document.getElementById('page-dashboard'); + const template = document.getElementById('chimera-home-command-center-template'); + if (!page || !template || document.getElementById('chimera-home-command-center')) return; + const launchpad = document.createElement('section'); + launchpad.id = 'chimera-home-command-center'; + launchpad.setAttribute('aria-label', 'Chimera status and next steps'); + launchpad.append(template.content.cloneNode(true)); + const pageHeader = page.querySelector(':scope > .page-header'); + if (pageHeader) pageHeader.insertAdjacentElement('afterend', launchpad); + else page.prepend(launchpad); + } + + function mountSettingsNavigator() { + const page = document.getElementById('page-settings'); + if (!page || document.getElementById('chimera-settings-navigator')) return; + const cards = [...page.querySelectorAll(':scope > .card')]; + if (!cards.length) return; + + const categoryFor = (card, label) => { + const id = String(card.id || ''); + const value = `${id} ${label}`.toLowerCase(); + if (/appearance|language/.test(value)) return 'setup'; + if (/console profile|console identity|ftp connection|payload manager|cheats manager|save manager/.test(value)) return 'console'; + if (/game source|conversion|remote game|metadata|ps4 package|package preservation/.test(value)) return 'storage'; + if (/automation advisor/.test(value)) return 'automation'; + if (/two-factor|passkey|security/.test(value)) return 'security'; + if (/payload remote|integration|notification|companion/.test(value)) return 'integrations'; + return 'advanced'; + }; + const sections = cards.map((card, index) => { + const heading = card.querySelector('h2, h3, h4'); + const label = heading?.textContent?.trim() || `${tr('settingsNavigator.section', 'Section')} ${index + 1}`; + if (!card.id) card.id = `settings-section-${index + 1}`; + card.style.scrollMarginTop = '108px'; + const category = categoryFor(card, label); + card.dataset.settingsCategory = category; + return { card, id: card.id, label, category }; + }); + const categories = [ + ['setup', tr('settingsNavigator.setup', 'Setup')], + ['console', tr('settingsNavigator.console', 'Console')], + ['storage', tr('settingsNavigator.storage', 'Storage')], + ['automation', tr('settingsNavigator.automation', 'Automation')], + ['integrations', tr('settingsNavigator.integrations', 'Integrations')], + ['security', tr('settingsNavigator.security', 'Security')], + ['advanced', tr('settingsNavigator.advanced', 'Advanced')], + ]; + let activeCategory = 'setup'; + + const navigator = document.createElement('section'); + navigator.id = 'chimera-settings-navigator'; + navigator.className = 'chimera-settings-navigator'; + navigator.setAttribute('aria-label', 'Search and navigate settings'); + navigator.innerHTML = ` +
    + Find a setting quickly + ${sections.length} ${tr('settingsNavigator.sections', 'sections')} +
    +
    + + +
    +
    + ${categories.map(([id, label], index) => ``).join('')} +
    `; + + const pageHeader = page.querySelector(':scope > .page-header'); + if (pageHeader) pageHeader.insertAdjacentElement('afterend', navigator); + else page.prepend(navigator); + + const input = navigator.querySelector('#chimera-settings-search'); + const clearButton = navigator.querySelector('#chimera-settings-clear'); + const status = navigator.querySelector('#chimera-settings-filter-status'); + const jump = navigator.querySelector('#chimera-settings-jump'); + const syncSections = () => { + sections.forEach((section, index) => { + const liveLabel = section.card.querySelector('h2, h3, h4')?.textContent?.trim() || section.label; + section.label = liveLabel; + if (jump.options[index + 1]) jump.options[index + 1].textContent = liveLabel; + }); + }; + const applyFilter = value => { + const query = String(value || '').trim().toLocaleLowerCase(activeLocale()); + let visible = 0; + sections.forEach(section => { + const searchText = section.card.textContent.toLocaleLowerCase(activeLocale()); + const matchesCategory = activeCategory === 'all' || section.category === activeCategory; + const matches = matchesCategory && (!query || searchText.includes(query)); + section.card.toggleAttribute('data-settings-filter-hidden', !matches); + if (matches) visible += 1; + }); + clearButton.hidden = !query; + status.textContent = query || activeCategory !== 'all' + ? `${visible} ${tr('settingsNavigator.of', 'of')} ${sections.length} ${tr('settingsNavigator.sections', 'sections')}` + : `${sections.length} ${tr('settingsNavigator.sections', 'sections')}`; + }; + applyFilter(''); + + navigator.querySelector('.chimera-settings-categories').addEventListener('click', event => { + const button = event.target.closest('[data-settings-category]'); + if (!button) return; + activeCategory = button.dataset.settingsCategory; + navigator.querySelectorAll('[data-settings-category]').forEach(candidate => candidate.setAttribute('aria-selected', String(candidate === button))); + applyFilter(input.value); + }); + input.addEventListener('input', event => applyFilter(event.target.value)); + clearButton.addEventListener('click', () => { + input.value = ''; + applyFilter(''); + input.focus(); + }); + jump.addEventListener('focus', syncSections); + jump.addEventListener('pointerdown', syncSections); + jump.addEventListener('change', event => { + const section = sections.find(item => item.id === event.target.value); + if (!section) return; + input.value = ''; + activeCategory = section.category; + navigator.querySelectorAll('[data-settings-category]').forEach(candidate => candidate.setAttribute('aria-selected', String(candidate.dataset.settingsCategory === activeCategory))); + applyFilter(''); + section.card.scrollIntoView({ + behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth', + block: 'center', + }); + event.target.value = ''; + }); + window.addEventListener('chimera:language-changed', () => { + syncSections(); + applyFilter(input.value); + }); + window.setTimeout(syncSections, 0); + } + + function setExpandedGroup(groupId, expanded) { + if (expanded) { + for (const openGroup of [...expandedGroups]) { + if (openGroup !== groupId) setExpandedGroup(openGroup, false); + } + } + if (expanded) expandedGroups.add(groupId); else expandedGroups.delete(groupId); + const domain = [...document.querySelectorAll('[data-nav-group]')].find(item => item.dataset.navGroup === groupId); + if (!domain) return; + const button = domain.querySelector('[data-group-toggle]'); + const routes = domain.querySelector('[data-group-routes]'); + if (button) button.setAttribute('aria-expanded', String(expanded)); + if (routes) routes.hidden = !expanded; + domain.classList.toggle('expanded', expanded); + } + + function updateSidebar(route) { + const group = groupById.get(route.group); + if (group && group.id !== 'home') setExpandedGroup(group.id, true); + document.querySelectorAll('[data-route]:not([data-payload-stage])').forEach(element => { + const active = element.dataset.route === route.id; + element.classList.toggle('active', active); + if (element.matches('a,button')) { + if (active) element.setAttribute('aria-current', 'page'); + else element.removeAttribute('aria-current'); + } + }); + updatePayloadWorkspaceStage(route.id); + document.querySelectorAll('[data-nav-group]').forEach(element => element.classList.toggle('active', element.dataset.navGroup === route.group)); + document.querySelectorAll('#chimera-companion-dock [data-companion-group]').forEach(element => { + const active = element.dataset.route === 'library/games' + ? route.id === 'library/games' + : element.dataset.companionGroup === route.group; + element.classList.toggle('active', active); + if (active) element.setAttribute('aria-current', 'page'); + else element.removeAttribute('aria-current'); + }); + document.getElementById('chimera-companion-more')?.classList.toggle( + 'active', + !['home', 'library', 'console'].includes(route.group), + ); + } + + function defaultPayloadStage(routeId) { + if (routeId === 'payloads/updates') return 'review'; + if (routeId === 'payloads/boot') return 'boot'; + if (routeId === 'payloads/installed') return 'discover'; + return null; + } + + function updatePayloadWorkspaceStage(routeId, requestedStage = null) { + const currentButton = document.querySelector(`[data-payload-stage="${activePayloadStage}"]`); + const stage = requestedStage + || (currentButton?.dataset.route === routeId ? activePayloadStage : defaultPayloadStage(routeId)); + if (!stage) return; + activePayloadStage = stage; + const stages = ['discover', 'review', 'stage', 'boot']; + const currentIndex = stages.indexOf(stage); + document.querySelectorAll('[data-payload-stage]').forEach(button => { + const buttonIndex = stages.indexOf(button.dataset.payloadStage); + const current = button.dataset.payloadStage === stage; + button.classList.toggle('is-current', current); + button.classList.toggle('is-complete', buttonIndex >= 0 && buttonIndex < currentIndex); + button.setAttribute('aria-pressed', String(current)); + if (current) button.setAttribute('aria-current', 'step'); + else button.removeAttribute('aria-current'); + }); + } + + function localizeSidebar() { + contract.routes.forEach(route => { + document.querySelectorAll(`.chimera-route-link[data-route="${route.id}"]`).forEach(link => { + const label = link.querySelector(':scope > span:first-child'); + if (label) label.textContent = routeLabel(route); + link.title = routeDescription(route); + }); + }); + contract.groups.forEach(group => { + const label = document.querySelector(`[data-nav-group="${group.id}"] .chimera-domain-button > span:not(.chimera-domain-chevron)`); + if (label) label.textContent = groupLabel(group); + }); + const aboutLabel = document.querySelector('#sidebar-donate .donate-label'); + if (aboutLabel) aboutLabel.textContent = tr('navigation.about', 'About Chimera'); + } + + function updateRouteHeader(route) { + const group = groupById.get(route.group); + const localizedGroup = groupLabel(group); + const localizedLabel = routeLabel(route); + const localizedDescription = routeDescription(route); + const breadcrumbs = document.getElementById('chimera-breadcrumbs'); + if (breadcrumbs) breadcrumbs.innerHTML = route.id === 'home' + ? 'Home' + : `${escapeText(localizedLabel)}`; + const title = document.getElementById('chimera-route-title'); + const description = document.getElementById('chimera-route-description'); + const risk = document.getElementById('chimera-route-risk'); + if (title) title.textContent = localizedLabel; + if (description) description.textContent = localizedDescription; + if (risk) risk.hidden = route.risk !== 'confirm'; + document.title = `${localizedLabel} — Chimera`; + } + + function clearRouteFocus() { + document.querySelectorAll('[data-route-hidden="true"]').forEach(element => element.removeAttribute('data-route-hidden')); + document.querySelectorAll('[data-route-focus]').forEach(element => element.removeAttribute('data-route-focus')); + } + + function hideElement(element) { + if (element) element.setAttribute('data-route-hidden', 'true'); + } + + function showOnlyPageChildren(pageId, allowedIds) { + const page = document.getElementById(pageId); + if (!page) return; + const allowed = new Set(allowedIds); + [...page.children].forEach(child => { + if (child.classList.contains('page-header') || child.hasAttribute('data-route-persistent')) return; + if (!child.id || !allowed.has(child.id)) hideElement(child); + }); + } + + function applyJailbreakFocus(focus) { + if (!focus) return; + const profiles = { + payloads: ['ps5-payload-manager-card', 'console-services-card', 'payload-update-card', 'payload-manager-card'], + boot: ['payload-manager-card', 'y2jb-generator-card', 'bdjb-generator-card'], + power: ['system-state-card'], + }; + showOnlyPageChildren('page-jailbreak', profiles[focus] || profiles.payloads); + const workspaceNav = document.querySelector('#page-jailbreak > .payload-workspace-nav'); + if (workspaceNav && focus === 'power') hideElement(workspaceNav); + const sectionVisibility = { + payloads: ['payload-local-section', 'payload-sender-section'], + boot: ['payload-autoloader-section', 'autoloader-snapshots-section'], + }; + if (sectionVisibility[focus]) { + const visible = new Set(sectionVisibility[focus]); + document.querySelectorAll('#payload-manager-card > .jb-section').forEach(section => { + if (!visible.has(section.id)) hideElement(section); + }); + } + document.getElementById('page-jailbreak')?.setAttribute('data-route-focus', focus); + } + + function applyReliabilityFocus(focus) { + if (!focus) return; + const visibleIds = { + replication: ['operations-reliability-warning', 'operations-replication-card'], + monitoring: ['operations-monitoring-card'], + security: ['operations-sessions-card', 'operations-password-card'], + }[focus]; + if (!visibleIds) return; + const visible = new Set(visibleIds); + document.querySelectorAll('[data-operations-panel="reliability"] .card[id]').forEach(card => { + if (!visible.has(card.id)) hideElement(card); + }); + document.querySelectorAll('[data-operations-panel="reliability"] .operations-two-column').forEach(column => { + const cards = [...column.querySelectorAll(':scope > .card')]; + if (cards.length && cards.every(card => card.hasAttribute('data-route-hidden'))) hideElement(column); + }); + document.querySelector('[data-operations-panel="reliability"]')?.setAttribute('data-route-focus', focus); + } + + function applyRouteFocus(route) { + if (route.page === 'jailbreak') return applyJailbreakFocus(route.focus); + if (route.page === 'operations' && route.panel === 'reliability') return applyReliabilityFocus(route.focus); + } + + function activatePage(page) { + document.querySelectorAll('.page').forEach(element => element.classList.toggle('active', element.id === `page-${page}`)); + const isEmbed = page === 'savemgr'; + document.getElementById('content')?.classList.toggle('embed-page-active', isEmbed); + if (!isEmbed) { + const content = document.getElementById('content'); + if (content) content.scrollTop = 0; + } + closeMobileNav(); + if (typeof syncPinStates === 'function') syncPinStates(); + if (typeof scheduleBackgroundWarm === 'function') scheduleBackgroundWarm(800); + } + + function selectEcosystemPanel(tab) { + document.querySelectorAll('.ecosystem-tab').forEach(button => { + const active = button.dataset.ecosystemTab === tab; + button.classList.toggle('active', active); + button.setAttribute('aria-selected', String(active)); + button.tabIndex = active ? 0 : -1; + }); + document.querySelectorAll('.ecosystem-panel').forEach(panel => { + const active = panel.dataset.ecosystemPanel === tab; + panel.classList.toggle('active', active); + panel.hidden = !active; + }); + } + + async function loadEcosystemRoute(route, force) { + const wasStale = force || pageIsStale('ecosystem'); + window.chimeraRequestedEcosystemTab = route.tab; + await ensurePageReady('ecosystem', { force: !!force }); + selectEcosystemPanel(route.tab); + if (!wasStale && typeof loadEcosystemTab === 'function') await loadEcosystemTab(route.tab); + } + + async function loadOperationsRoute(route, force) { + await ensurePageReady('operations', { force: !!force }); + document.querySelectorAll('.operations-tab').forEach(button => { + const active = button.dataset.operationsTab === route.panel; + button.classList.toggle('active', active); + button.setAttribute('aria-selected', String(active)); + button.tabIndex = active ? 0 : -1; + }); + document.querySelectorAll('.operations-panel').forEach(panel => { + const active = panel.dataset.operationsPanel === route.panel; + panel.classList.toggle('active', active); + panel.hidden = !active; + }); + if (route.panel === 'shadowmount' && typeof loadOperationsShadowMount === 'function') await loadOperationsShadowMount(); + if (route.panel === 'packages' && typeof loadOperationsPackages === 'function') await loadOperationsPackages(); + if (route.panel === 'reliability' && typeof loadOperationsReliability === 'function') await loadOperationsReliability({ focus: route.focus }); + if (route.panel === 'audit' && typeof loadOperationsAudit === 'function') await loadOperationsAudit(); + if (route.panel === 'diagnostics' && typeof loadKlog === 'function') await loadKlog(); + } + + async function hydrateRoute(route, force) { + if (route.page === 'ecosystem') await loadEcosystemRoute(route, force); + else if (route.page === 'operations') await loadOperationsRoute(route, force); + else await ensurePageReady(route.page, { force: !!force }); + } + + function writeHistory(routeId, mode) { + const url = routeUrl(routeId); + if (mode === 'push') window.history.pushState({ chimeraRoute: routeId }, '', url); + if (mode === 'replace') window.history.replaceState({ chimeraRoute: routeId }, '', url); + } + + async function navigateRoute(value, options = {}) { + const routeId = normalizeRoute(value); + const route = routeById.get(routeId); + const historyMode = options.history ?? 'push'; + if (!route) return; + if (currentRouteId === routeId && !options.force) { + closeMobileNav(); + if (historyMode === 'replace') writeHistory(routeId, 'replace'); + return; + } + + const sequence = ++navigationSequence; + clearRouteFocus(); + currentRouteId = routeId; + if (historyMode !== 'none') writeHistory(routeId, historyMode); + updateSidebar(route); + updateRouteHeader(route); + // Apply deep-route disclosure while the destination page is still hidden. + // This prevents unrelated Reliability cards from flashing for one frame. + applyRouteFocus(route); + activatePage(route.page); + document.body.setAttribute('data-chimera-route', route.id); + + const refresh = document.getElementById('chimera-route-refresh'); + const availability = document.getElementById('chimera-route-availability'); + if (availability) availability.hidden = true; + if (refresh) { refresh.disabled = true; refresh.textContent = tr('common.loading', 'Loading…'); } + try { + await window.chimeraI18nReady; + if (sequence !== navigationSequence) return; + await hydrateRoute(route, options.force); + if (sequence !== navigationSequence) return; + applyRouteFocus(route); + } catch (error) { + if (typeof isExpectedUnavailable === 'function' && isExpectedUnavailable(error)) { + console.info(`[navigation] ${route.id} temporarily unavailable: ${error.message}`); + if (availability) { + availability.textContent = `${tr('navigation.unavailable', 'Unavailable')}: ${friendlyMessage(error?.message || error)}`; + availability.hidden = false; + } + } else { + console.error(`[navigation] ${route.id}`, error); + const failure = `${tr('navigation.loadFailed', 'Could not fully load')} ${routeLabel(route)}: ${friendlyMessage(error?.message || error)}`; + if (availability) { + availability.textContent = `${failure}. ${tr('navigation.retryHint', 'Use Refresh to retry.')}`; + availability.hidden = false; + } else if (typeof showToast === 'function') { + showToast(failure, 'error', 9000); + } + } + } finally { + if (sequence === navigationSequence && refresh) { refresh.disabled = false; refresh.innerHTML = `↻ ${escapeText(tr('common.refresh', 'Refresh'))}`; } + } + } + + function openCommandPalette() { + const overlay = document.getElementById('chimera-command-overlay'); + if (!overlay) return; + document.body.classList.add('chimera-modal-open'); + const input = document.getElementById('chimera-command-input'); + input.value = ''; + document.getElementById('chimera-command-clear')?.setAttribute('hidden', ''); + renderCommandResults(''); + if (typeof openAccessibleDialog === 'function') openAccessibleDialog(overlay, input); + else { overlay.hidden = false; requestAnimationFrame(() => input.focus()); } + } + + function closeCommandPalette() { + const overlay = document.getElementById('chimera-command-overlay'); + if (typeof closeAccessibleDialog === 'function') closeAccessibleDialog(overlay); + else if (overlay) overlay.hidden = true; + if (document.getElementById('chimera-jobs-overlay')?.hidden !== false) document.body.classList.remove('chimera-modal-open'); + } + + function routeMatches(route, query) { + const group = groupById.get(route.group); + return [route.label, routeLabel(route), route.description, routeDescription(route), route.id, group?.label, groupLabel(group), ...(route.keywords || [])].join(' ').toLocaleLowerCase(activeLocale()).includes(query); + } + + function commandRouteMarkup(routes) { + if (!routes.length) return ''; + return `${routes.map(route => { + const group = groupById.get(route.group); + return ``; + }).join('')}`; + } + + function commandGameMarkup(games) { + if (!games.length) return ''; + return `${games.map(game => { + const name = game.prospero_name || game.title || game.game_id; + return ``; + }).join('')}`; + } + + function recentCommandQueries() { + try { + const value = JSON.parse(localStorage.getItem('chimera.search.recent') || '[]'); + return Array.isArray(value) ? value.filter(item => typeof item === 'string' && item.trim()).slice(0, 6) : []; + } catch (_) { return []; } + } + + function rememberCommandQuery(value) { + const query = String(value || '').trim(); + if (query.length < 2) return; + const recent = [query, ...recentCommandQueries().filter(item => item.toLocaleLowerCase(activeLocale()) !== query.toLocaleLowerCase(activeLocale()))].slice(0, 6); + localStorage.setItem('chimera.search.recent', JSON.stringify(recent)); + } + + function recentCommandMarkup() { + const recent = recentCommandQueries(); + if (!recent.length) return ''; + return `
    ${recent.map(query => ``).join('')}
    `; + } + + function resetCommandSelection() { + const first = document.querySelector('#chimera-command-results .chimera-command-result'); + if (first) { + first.classList.add('selected'); + first.setAttribute('aria-selected', 'true'); + } + } + + function updateCommandStatus(message, busy = false) { + const results = document.getElementById('chimera-command-results'); + const status = document.getElementById('chimera-command-status'); + if (results) results.setAttribute('aria-busy', String(busy)); + if (status) status.textContent = message; + } + + async function getCommandGames(query = '') { + if (!window.pork.isWebMode && Array.isArray(state?.games) && state.games.length) return state.games; + const cacheKey = String(query || '').trim().toLocaleLowerCase(activeLocale()); + if (commandGameCache?.key === cacheKey && Date.now() - commandGameCacheAt < 60_000) return commandGameCache.games; + const result = await window.pork.listGames(cacheKey ? { search: cacheKey, limit: 24 } : { limit: 24 }); + commandGameCache = { key: cacheKey, games: Array.isArray(result) ? result : (result?.games || []) }; + commandGameCacheAt = Date.now(); + return commandGameCache.games; + } + + async function renderCommandResults(value) { + const results = document.getElementById('chimera-command-results'); + if (!results) return; + const query = String(value || '').trim().toLocaleLowerCase(activeLocale()); + const sequence = ++commandSearchSequence; + const clearButton = document.getElementById('chimera-command-clear'); + if (clearButton) clearButton.hidden = !query; + const availableRoutes = contract.routes.filter(route => expertMode || !route.expert); + const routes = (query ? availableRoutes.filter(route => routeMatches(route, query)) : availableRoutes.filter(route => ['home', 'library/games', 'console/status', 'payloads/installed', 'tools/transfers', 'settings/general'].includes(route.id))).slice(0, 12); + const recentMarkup = query ? '' : recentCommandMarkup(); + results.innerHTML = `${recentMarkup}${commandRouteMarkup(routes)}` || `

    ${escapeText(tr('navigation.search.noFeature', 'No feature found. Keep typing to search games.'))}

    `; + resetCommandSelection(); + if (query.length < 2) { + updateCommandStatus(query + ? `${routes.length} ${tr('navigation.search.featuresLower', 'features')} · ${tr('navigation.search.oneMore', 'type 1 more character for games')}` + : `${routes.length} ${tr('navigation.search.quickRoutes', 'quick routes')}`); + return; + } + updateCommandStatus(`${routes.length} ${tr('navigation.search.featuresLower', 'features')} · ${tr('navigation.search.searchingGames', 'searching games…')}`, true); + try { + const games = (await getCommandGames(query)).filter(game => [game.prospero_name, game.title, game.game_id, game.prospero_publisher].filter(Boolean).join(' ').toLocaleLowerCase(activeLocale()).includes(query)).slice(0, 8); + if (sequence !== commandSearchSequence) return; + results.innerHTML = `${commandRouteMarkup(routes)}${commandGameMarkup(games)}` || `

    ${escapeText(tr('navigation.search.noMatches', 'No matches found.'))}

    `; + resetCommandSelection(); + updateCommandStatus(`${routes.length + games.length} ${tr('navigation.search.results', 'results')}`); + } catch (_) { + if (!routes.length && sequence === commandSearchSequence) results.innerHTML = `

    ${escapeText(tr('navigation.search.gamesUnavailable', 'Game search is currently unavailable.'))}

    `; + if (sequence === commandSearchSequence) updateCommandStatus(routes.length + ? `${routes.length} ${tr('navigation.search.featuresLower', 'features')} · ${tr('navigation.search.gamesUnavailableShort', 'games unavailable')}` + : tr('navigation.search.noResults', 'No results')); + } + } + + async function activateCommandResult(button) { + if (!button) return; + rememberCommandQuery(document.getElementById('chimera-command-input')?.value); + closeCommandPalette(); + if (button.dataset.commandRoute) return navigateRoute(button.dataset.commandRoute); + if (button.dataset.commandGame) { + await navigateRoute('library/games'); + const input = document.getElementById('games-search'); + if (input) { + input.value = button.dataset.commandGame; + input.dispatchEvent(new Event('input', { bubbles: true })); + } + } + } + + function moveCommandSelection(direction) { + const buttons = [...document.querySelectorAll('#chimera-command-results .chimera-command-result')]; + if (!buttons.length) return; + let index = buttons.findIndex(button => button.classList.contains('selected')); + buttons.forEach(button => { + button.classList.remove('selected'); + button.setAttribute('aria-selected', 'false'); + }); + index = (index + direction + buttons.length) % buttons.length; + buttons[index].classList.add('selected'); + buttons[index].setAttribute('aria-selected', 'true'); + buttons[index].scrollIntoView({ block: 'nearest' }); + } + + function transferStatusLabel(status) { + const fallback = { + queued: 'Queued', active: 'Active', done: 'Completed', error: 'Error', + cancelled: 'Cancelled', interrupted: 'Interrupted', retrying: 'Retry scheduled', + }[status] || status || 'Unknown'; + return tr(`navigation.jobs.status.${status || 'unknown'}`, fallback); + } + + function renderJobs(stateValue) { + currentTransferState = stateValue || currentTransferState; + const active = Number(currentTransferState.activeCount || 0); + const queued = Number(currentTransferState.queuedCount || 0); + const count = active + queued; + const homeTransferTruth = document.getElementById('home-truth-transfers'); + if (homeTransferTruth) { + homeTransferTruth.textContent = active + ? `${active} active` + : queued + ? `${queued} queued` + : currentTransferState.paused + ? 'Queue paused' + : 'No active work'; + } + const badge = document.getElementById('chimera-job-count'); + const navBadge = document.getElementById('nav-transfer-badge'); + if (badge) { + const total = count + currentActionCount; + badge.hidden = total === 0; + badge.textContent = String(total); + } + if (navBadge) { + navBadge.hidden = count === 0; + navBadge.textContent = String(count); + } + const companionBadge = document.getElementById('chimera-companion-count'); + if (companionBadge) { + const total = count + currentActionCount; + companionBadge.hidden = total === 0; + companionBadge.textContent = String(total); + } + document.querySelector('.chimera-jobs-pulse')?.classList.toggle('active', active > 0); + const summary = document.getElementById('chimera-jobs-summary'); + const list = document.getElementById('chimera-jobs-list'); + if (summary) summary.innerHTML = `
    ${active}${escapeText(tr('navigation.jobs.active', 'active'))}
    ${queued}${escapeText(tr('navigation.jobs.queued', 'queued'))}
    ${escapeText(currentTransferState.paused ? tr('common.yes', 'Yes') : tr('common.no', 'No'))}${escapeText(tr('navigation.jobs.paused', 'paused'))}
    `; + if (!list) return; + const jobs = (currentTransferState.jobs || []).filter(job => job.status !== 'cancelled').slice(-20).reverse(); + list.innerHTML = jobs.length ? jobs.map(job => { + const progress = Math.max(0, Math.min(100, Number(job.progress?.percent || (job.status === 'done' ? 100 : 0)))); + return `
    ${escapeText(job.label || job.progress?.file || 'Transfer')}${escapeText(transferStatusLabel(job.status))}${job.progress?.file ? ` · ${escapeText(job.progress.file)}` : ''}
    ${Math.round(progress)}%
    ${job.error ? `${escapeText(job.error)}` : ''}
    `; + }).join('') : `
    ${escapeText(tr('navigation.jobs.emptyTitle', 'No transfers'))}${escapeText(tr('navigation.jobs.emptyDetail', 'New uploads and downloads will appear here automatically.'))}
    `; + } + + function actionRoute(action = {}) { + if (action.tab) return contract.routes.find(route => route.page === 'ecosystem' && route.tab === action.tab)?.id || 'console/status'; + if (action.panel) return contract.routes.find(route => route.page === 'operations' && route.panel === action.panel)?.id || 'settings/automation'; + return contract.legacyAliases[action.page] || (routeById.has(action.page) ? action.page : 'console/status'); + } + + function renderActions(packet) { + const list = document.getElementById('chimera-action-list'); + if (!list) return; + const report = packet?.report || null; + const alerts = (report?.alerts || []).filter(alert => !alert.acknowledged).slice(0, 12); + currentActionCount = alerts.length; + const badge = document.getElementById('chimera-job-count'); + if (badge) { + const total = Number(currentTransferState.activeCount || 0) + Number(currentTransferState.queuedCount || 0) + currentActionCount; + badge.hidden = total === 0; + badge.textContent = String(total); + } + const companionBadge = document.getElementById('chimera-companion-count'); + if (companionBadge) { + const total = Number(currentTransferState.activeCount || 0) + Number(currentTransferState.queuedCount || 0) + currentActionCount; + companionBadge.hidden = total === 0; + companionBadge.textContent = String(total); + } + if (!report) { + list.innerHTML = `
    ${escapeText(tr('navigation.actionCenter.notScanned', 'No health scan yet'))}${escapeText(tr('navigation.actionCenter.notScannedDetail', 'Open Console health to run the first bounded scan.'))}
    `; + return; + } + list.innerHTML = alerts.length ? alerts.map(alert => { + const count = String(alert.title || '').match(/\d+(?:\/\d+)?/)?.[0] || ''; + const values = { count }; + const translatedTitle = window.tf?.(`navigation.alerts.${alert.id}.title`, alert.title, values) || alert.title; + const translatedDetail = window.tf?.(`navigation.alerts.${alert.id}.detail`, alert.detail, values) || alert.detail; + return ` +
    + +
    ${escapeText(translatedTitle)}${escapeText(translatedDetail)}
    + +
    `; + }).join('') : `
    ${escapeText(tr('navigation.actionCenter.clear', 'Nothing needs attention'))}${escapeText(tr('navigation.actionCenter.clearDetail', 'No open alerts were found in the latest health scan.'))}
    `; + } + + async function openJobsDrawer() { + const overlay = document.getElementById('chimera-jobs-overlay'); + if (!overlay) return; + document.body.classList.add('chimera-modal-open'); + const [transfers, health] = await Promise.allSettled([ + window.pork.transferState(), + window.pork.ecosystemHealthLatest(), + ]); + renderJobs(transfers.status === 'fulfilled' ? transfers.value : currentTransferState); + renderActions(health.status === 'fulfilled' ? health.value : null); + const closeButton = overlay.querySelector('[data-jobs-close]'); + if (typeof openAccessibleDialog === 'function') openAccessibleDialog(overlay, closeButton); + else { overlay.hidden = false; requestAnimationFrame(() => closeButton?.focus()); } + } + + function closeJobsDrawer() { + const overlay = document.getElementById('chimera-jobs-overlay'); + if (typeof closeAccessibleDialog === 'function') closeAccessibleDialog(overlay); + else if (overlay) overlay.hidden = true; + if (document.getElementById('chimera-command-overlay')?.hidden !== false) document.body.classList.remove('chimera-modal-open'); + } + + function openAboutDialog() { + const overlay = document.getElementById('chimera-about-overlay'); + if (!overlay) return; + const closeButton = overlay.querySelector('[data-about-close]'); + if (typeof openAccessibleDialog === 'function') openAccessibleDialog(overlay, closeButton); + else { overlay.hidden = false; requestAnimationFrame(() => closeButton?.focus()); } + } + + function closeAboutDialog() { + const overlay = document.getElementById('chimera-about-overlay'); + if (typeof closeAccessibleDialog === 'function') closeAccessibleDialog(overlay); + else if (overlay) overlay.hidden = true; + } + + function bindEvents() { + document.addEventListener('click', async event => { + const routeTarget = event.target.closest('[data-route]'); + if (routeTarget) { + event.preventDefault(); + closeCommandPalette(); + closeJobsDrawer(); + closeAboutDialog(); + const requestedStage = routeTarget.dataset.payloadStage + || (!routeTarget.closest('.payload-workspace-nav') ? defaultPayloadStage(routeTarget.dataset.route) : null); + if (requestedStage) activePayloadStage = requestedStage; + await navigateRoute(routeTarget.dataset.route); + const focusTarget = routeTarget.dataset.focusTarget + ? document.getElementById(routeTarget.dataset.focusTarget) + : null; + if (focusTarget) { + focusTarget.scrollIntoView({ + behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth', + block: 'center', + }); + requestAnimationFrame(() => focusTarget.focus({ preventScroll: true })); + } + if (requestedStage) updatePayloadWorkspaceStage(routeTarget.dataset.route, requestedStage); + const payloadTarget = routeTarget.dataset.payloadTarget + ? document.getElementById(routeTarget.dataset.payloadTarget) + : null; + if (payloadTarget) { + payloadTarget.scrollIntoView({ behavior: 'smooth', block: 'start' }); + payloadTarget.classList.remove('payload-workspace-highlight'); + requestAnimationFrame(() => payloadTarget.classList.add('payload-workspace-highlight')); + window.setTimeout(() => payloadTarget.classList.remove('payload-workspace-highlight'), 1800); + } + return; + } + const groupToggle = event.target.closest('[data-group-toggle]'); + if (groupToggle) { + const groupId = groupToggle.dataset.groupToggle; + setExpandedGroup(groupId, !expandedGroups.has(groupId)); + } + const commandResult = event.target.closest('[data-command-route], [data-command-game]'); + if (commandResult) activateCommandResult(commandResult); + const commandQuery = event.target.closest('[data-command-query]'); + if (commandQuery) { + const input = document.getElementById('chimera-command-input'); + if (input) { + input.value = commandQuery.dataset.commandQuery; + renderCommandResults(input.value); + input.focus(); + } + } + if (event.target.closest('[data-command-close]') || event.target.id === 'chimera-command-overlay') closeCommandPalette(); + if (event.target.closest('[data-jobs-close]') || event.target.id === 'chimera-jobs-overlay') closeJobsDrawer(); + if (event.target.closest('[data-about-close]') || event.target.id === 'chimera-about-overlay') closeAboutDialog(); + }); + + document.getElementById('chimera-command-open')?.addEventListener('click', openCommandPalette); + document.getElementById('chimera-jobs-open')?.addEventListener('click', openJobsDrawer); + document.getElementById('chimera-companion-actions')?.addEventListener('click', openJobsDrawer); + document.getElementById('chimera-companion-more')?.addEventListener('click', event => { + document.getElementById('mobile-nav-toggle')?.click(); + event.currentTarget.setAttribute('aria-expanded', String(document.body.classList.contains('mobile-nav-open'))); + }); + document.getElementById('chimera-route-refresh')?.addEventListener('click', () => navigateRoute(currentRouteId, { history: 'none', force: true })); + document.getElementById('chimera-expert-mode')?.addEventListener('change', async event => { + expertMode = event.target.checked; + document.body.classList.toggle('chimera-expert-mode', expertMode); + await syncExpertPermission(); + window.dispatchEvent(new CustomEvent('chimera:expert-mode-changed', { + detail: { enabled: expertMode }, + })); + if (!expertMode && routeById.get(currentRouteId)?.expert) navigateRoute(currentRouteId, { history: 'replace', force: true }); + }); + document.getElementById('chimera-command-input')?.addEventListener('input', event => renderCommandResults(event.target.value)); + document.getElementById('chimera-command-clear')?.addEventListener('click', () => { + const input = document.getElementById('chimera-command-input'); + if (!input) return; + input.value = ''; + renderCommandResults(''); + input.focus(); + }); + document.getElementById('chimera-command-input')?.addEventListener('keydown', event => { + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault(); moveCommandSelection(event.key === 'ArrowDown' ? 1 : -1); } + if (event.key === 'Enter') { event.preventDefault(); activateCommandResult(document.querySelector('#chimera-command-results .chimera-command-result.selected')); } + }); + document.addEventListener('keydown', event => { + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') { event.preventDefault(); openCommandPalette(); } + if (event.key === 'Escape') { closeCommandPalette(); closeJobsDrawer(); closeAboutDialog(); } + }); + const syncFromLocation = () => navigateRoute(routeFromLocation(), { history: 'none' }); + window.addEventListener('popstate', syncFromLocation); + window.addEventListener('hashchange', syncFromLocation); + window.addEventListener('chimera:language-changed', () => { + localizeSidebar(); + const route = routeById.get(currentRouteId); + if (route) updateRouteHeader(route); + const input = document.getElementById('chimera-command-input'); + if (input && document.getElementById('chimera-command-overlay')?.hidden === false) renderCommandResults(input.value); + renderJobs(currentTransferState); + if (document.getElementById('chimera-jobs-overlay')?.hidden === false) { + window.pork.ecosystemHealthLatest().then(renderActions).catch(() => renderActions(null)); + } + }); + window.pork?.on?.('transfer:update', renderJobs); + } + + mountSidebar(); + mountShell(); + mountCompanionDock(); + mountHomeCommandCenter(); + mountSettingsNavigator(); + bindEvents(); + window.chimeraOpenAbout = openAboutDialog; + + const compatibleNavigate = page => navigateRoute(contract.legacyAliases[page] || page); + navigate = compatibleNavigate; + window.navigate = compatibleNavigate; + window.chimeraNavigate = navigateRoute; + window.chimeraNavigationContract = contract; + + const initialRoute = routeFromLocation(); + const initialGroup = routeById.get(initialRoute)?.group; + if (initialGroup && initialGroup !== 'home') expandedGroups.add(initialGroup); + requestAnimationFrame(() => navigateRoute(initialRoute, { history: 'replace' })); + window.pork?.transferState?.().then(renderJobs).catch(() => {}); + window.pork?.ecosystemHealthLatest?.().then(renderActions).catch(() => {}); +})(); diff --git a/renderer/modules/operations.js b/renderer/modules/operations.js new file mode 100644 index 0000000..65f7841 --- /dev/null +++ b/renderer/modules/operations.js @@ -0,0 +1,700 @@ +'use strict'; + +let _operationsLoaded = false; +let _operationsStatus = null; +let _operationsBusy = new Set(); +let _operationsStorageReport = null; +let _operationsStorageTopology = null; + +function operationsDate(value) { + if (!value) return '—'; + try { return new Date(Number(value) || value).toLocaleString(window.i18n?.current || 'en'); } catch (_) { return '—'; } +} + +function operationsDuration(value) { + const ms = Number(value || 0); + if (!ms) return '—'; + if (ms < 1000) return `${ms} ms`; + if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`; + return `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`; +} + +function operationsBytes(value) { + const bytes = Number(value || 0); + if (!Number.isFinite(bytes) || bytes <= 0) return '—'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let amount = bytes; + let unit = 0; + while (amount >= 1024 && unit < units.length - 1) { amount /= 1024; unit += 1; } + return `${amount >= 10 || unit === 0 ? amount.toFixed(0) : amount.toFixed(1)} ${units[unit]}`; +} + +function operationsRunSummary(run) { + const raw = String(run.error || run.summary || '').trim(); + if (!raw) return run.status === 'completed' ? 'Completed' : ''; + if (run.error) return raw.length > 240 ? `${raw.slice(0, 237)}...` : raw; + let result; + try { result = JSON.parse(raw); } catch (_) { return raw.length > 240 ? `${raw.slice(0, 237)}...` : raw; } + if (result == null) return 'Completed'; + if (typeof result === 'string' || typeof result === 'number') return String(result); + if (run.task_id === 'library-scan') { + const total = Number(result.total ?? result.found?.length ?? result.found ?? 0); + const added = Number(result.added || 0); + return `${total} game${total === 1 ? '' : 's'} found · ${added} new`; + } + if (run.task_id === 'metadata-refresh') { + const queued = Number(result.queued ?? (typeof result === 'number' ? result : 0)); + return `${queued} metadata item${queued === 1 ? '' : 's'} queued`; + } + if (run.task_id === 'payload-sync' && Array.isArray(result)) { + const failed = result.filter(item => item?.error).length; + return `${result.length} source${result.length === 1 ? '' : 's'} checked${failed ? ` · ${failed} error${failed === 1 ? '' : 's'}` : ''}`; + } + if (run.task_id === 'cheat-sync') { + const indexed = Number(result.ecosystem?.total || 0); + const cached = Number(result.legacy?.total || 0); + const failed = Number(result.legacy?.failed || 0) + Number(result.ecosystem?.errors?.length || 0); + return `${indexed} modern cheats or patches indexed · ${cached} legacy items cached${failed ? ` · ${failed} error${failed === 1 ? '' : 's'}` : ''}`; + } + if (run.task_id === 'storage-audit') { + const summary = result.summary || {}; + const issues = Number(summary.missingBackups || 0) + Number(summary.partialFiles || 0) + Number(summary.orphanGameFolders || 0); + return `${Number(summary.games || 0)} games · ${Number(summary.backups || 0)} backups · ${issues} attention item${issues === 1 ? '' : 's'}`; + } + if (run.task_id === 'recovery-snapshot') { + const localFiles = Array.isArray(result.files) ? result.files.length : 0; + const remoteFiles = Array.isArray(result.remote) ? result.remote.filter(item => item.included).length : 0; + return `${localFiles} local and ${remoteFiles} PS5 files secured`; + } + const values = Object.entries(result).filter(([, value]) => ['string', 'number', 'boolean'].includes(typeof value)).slice(0, 3); + return values.length ? values.map(([key, value]) => `${key}: ${value}`).join(' · ') : 'Completed'; +} + +function operationsStatusBadge(status) { + const labels = { completed: 'Succeeded', failed: 'Failed', running: 'Running', compatible: 'Compatible', warning: 'Warning', unknown: 'Unknown' }; + return `${escHtml(labels[status] || status || 'Unknown')}`; +} + +async function operationsAction(key, button, task) { + if (_operationsBusy.has(key)) return null; + _operationsBusy.add(key); + const prior = button?.textContent; + if (button) { button.disabled = true; button.textContent = window.t('common.loading', 'Loading…'); } + try { return await task(); } + finally { + _operationsBusy.delete(key); + if (button) { button.disabled = false; button.textContent = prior; } + } +} + +function renderOperationsAutomation(data) { + _operationsStatus = data; + const scheduler = $('operations-scheduler-state'); + scheduler.textContent = data.running ? 'Active' : 'Stopped'; + scheduler.className = `badge ${data.running ? 'badge-green' : 'badge-red'}`; + const tasks = data.tasks || []; + $('operations-task-list').innerHTML = tasks.map(task => ` +
    +
    + +
    ${escHtml(task.label)}${task.running ? 'Running now' : task.enabled ? `Next: ${operationsDate(task.nextRunAt)}` : 'Disabled'}
    +
    + + + + +
    `).join('') || '

    No tasks configured.

    '; + + const runs = (data.recentRuns || []).slice(0, 25); + $('operations-run-list').innerHTML = runs.map(run => { + const summary = operationsRunSummary(run); + return ` +
    +
    ${escHtml(tasks.find(task => task.id === run.task_id)?.label || run.task_id)}${escHtml(run.trigger || 'manual')} · ${operationsDate(run.started_at)}
    + ${operationsStatusBadge(run.status)} + ${operationsDuration(run.duration_ms)} + ${escHtml(summary)} +
    `; + }).join('') || '

    No runs yet.

    '; + + const failed = runs.filter(run => run.status === 'failed').length; + const health = $('operations-health'); + health.textContent = failed + ? window.tf('operations.status.failures', '{count} recent failure(s)', { count: failed }) + : window.t('operations.status.healthy', 'Automation healthy'); + health.className = `operations-health ${failed ? 'warning' : 'healthy'}`; + const badge = $('nav-operations-badge'); + if (badge) { badge.hidden = failed === 0; badge.textContent = failed || ''; } +} + +function renderOperationsStorage(report) { + _operationsStorageReport = report; + if (!report) { + $('operations-storage-summary').innerHTML = ''; + $('operations-storage-details').innerHTML = '

    No storage report available yet.

    '; + return; + } + const summary = report.summary || {}; + const metrics = [ + [window.t('operations.storage.metrics.mounts', 'Mounts'), summary.roots], + [window.t('operations.storage.metrics.games', 'Games'), summary.games], + [window.t('operations.storage.metrics.backups', 'Backups'), summary.backups], + [window.t('operations.storage.metrics.missing', 'Missing'), summary.missingBackups], + [window.t('operations.storage.metrics.duplicates', 'Duplicate games'), summary.duplicateGames], + [window.t('operations.storage.metrics.partial', 'Partial files'), summary.partialFiles], + [window.t('operations.storage.metrics.orphaned', 'Orphaned folders'), summary.orphanGameFolders], + ]; + $('operations-storage-summary').innerHTML = metrics.map(([label, value]) => `
    ${Number(value || 0)}${escHtml(label)}
    `).join(''); + const sections = []; + if ((report.roots || []).length) sections.push(`

    ${escHtml(window.t('operations.storage.volumes', 'Volumes'))}

    ${report.roots.map(root => `
    ${escHtml(root.path)}${root.error ? escHtml(root.error) : `${operationsBytes(root.availableBytes)} ${escHtml(window.t('operations.storage.freeOf', 'free of'))} ${operationsBytes(root.totalBytes)}`}
    `).join('')}
    `); + const issue = (title, items, render) => { + if (!items?.length) return; + sections.push(`
    ${escHtml(title)} ${items.length}${items.slice(0, 100).map(render).join('')}${items.length > 100 ? `

    ${escHtml(window.t('operations.storage.first100', 'Only the first 100 items are shown.'))}

    ` : ''}
    `); + }; + issue(window.t('operations.storage.issues.missing', 'Missing backups'), report.missingBackups, item => `
    ${escHtml(item.gameId)}${escHtml(item.path)}
    `); + issue(window.t('operations.storage.issues.duplicates', 'Duplicate backup registrations'), report.duplicateBackups, item => `
    ${escHtml(item.gameId)} (${item.count})${item.paths.map(escHtml).join('
    ')}
    `); + issue(window.t('operations.storage.issues.partial', 'Partial files'), report.partialFiles, item => `
    ${escHtml(item)}
    `); + issue(window.t('operations.storage.issues.orphaned', 'Orphaned game folders'), report.orphanGameFolders, item => `
    ${escHtml(item.gameId)}${escHtml(item.path)}
    `); + if (!report.missingBackups?.length && !report.partialFiles?.length && !report.orphanGameFolders?.length) sections.push(`
    ✓ ${escHtml(window.t('operations.storage.healthy', 'No missing backups, partial files or orphaned game folders found.'))}
    `); + $('operations-storage-details').innerHTML = `

    ${escHtml(window.t('operations.storage.reportAt', 'Report from'))} ${operationsDate(report.generatedAt)}

    ${sections.join('')}`; +} + +function renderStorageTopology(packet) { + const target = $('operations-storage-topology'); + if (!target || !packet) return; + _operationsStorageTopology = packet; + target.innerHTML = `

    ${escHtml(window.t('operations.storage.consoleSource', 'Console source'))}: ${escHtml(packet.provenance?.console || 'unknown')} · ${escHtml(window.t('operations.storage.serverSource', 'server source'))}: ${escHtml(packet.provenance?.server || 'unknown')}

    ${(packet.volumes || []).map(volume => `
    ${escHtml(volume.scope === 'console' ? `PS5 ${volume.kind}` : volume.mountpoint)}${escHtml(volume.mountpoint || 'unknown')} · ${escHtml(volume.filesystem || 'unknown')} · ${volume.freeBytes == null ? escHtml(window.t('operations.storage.freeUnknown', 'free space unknown')) : `${operationsBytes(volume.freeBytes)} ${escHtml(window.t('operations.storage.free', 'free'))}`} · ${volume.totalBytes == null ? escHtml(window.t('operations.storage.totalUnknown', 'total unknown')) : operationsBytes(volume.totalBytes)}
    ${operationsStatusBadge(volume.status === 'unknown' || volume.source === 'unavailable' ? 'unknown' : volume.readOnly === true ? 'warning' : 'compatible')}
    `).join('')}
    `; +} + +async function loadStorageTopology() { + if (!window.pork.consoleStorageTopology) return; + renderStorageTopology(await window.pork.consoleStorageTopology()); +} + +function renderOperationsRecovery(snapshots) { + $('operations-recovery-list').innerHTML = (snapshots || []).map(snapshot => { + const included = (snapshot.remote || []).filter(item => item.included).length; + return `
    +
    ↺
    +
    ${escHtml(snapshot.label || 'Restore point')}${operationsDate(snapshot.createdAt)} · Chimera ${escHtml(snapshot.chimeraVersion || '?')} · ${included} remote files · ${snapshot.integrity === 'sha256' ? 'SHA-256' : 'legacy verification'}${escHtml(snapshot.id)}
    +
    +
    `; + }).join('') || '
    No restore points yetCreate a restore point before making major changes.
    '; +} + +function operationsAuditFilters() { + const since = $('operations-audit-since').value; + const until = $('operations-audit-until').value; + return { + level: $('operations-audit-level').value, + classification: $('operations-audit-classification').value, + result: $('operations-audit-result').value, + search: $('operations-audit-search').value.trim(), + since: since ? new Date(since).getTime() : null, + until: until ? new Date(until).getTime() : null, + limit: 200, + }; +} + +function renderOperationsAudit(result, health) { + const summary = result.summary || {}; + $('operations-audit-summary').innerHTML = [ + ['Matched', result.totalMatched || 0], ['Succeeded', summary.success || 0], ['Failed', summary.failed || 0], + ['Sensitive', summary.sensitive || 0], ['Destructive', summary.destructive || 0], ['Errors', summary.errors || 0], + ].map(([label, value]) => `
    ${Number(value)}${escHtml(label)}
    `).join(''); + const files = health?.files || []; + $('operations-audit-health').textContent = `${files.length} log file(s) · ${operationsBytes(health?.totalBytes)} · rotation ${operationsBytes(health?.retention?.maxBytes)} × ${Number(health?.retention?.maxFiles || 0)} · secrets redacted at rest`; + $('operations-audit-list').innerHTML = (result.entries || []).map(entry => `
    +
    ${escHtml(entry.command || entry.event || entry.level)}${operationsDate(entry.time)}${entry.requestIp ? ` · ${escHtml(entry.requestIp)}` : ''}
    + ${operationsStatusBadge(entry.ok === true ? 'completed' : entry.ok === false ? 'failed' : 'unknown')} + ${escHtml(entry.classification || entry.level)} + ${escHtml(entry.error || (entry.durationMs == null ? entry.event : `${entry.event} · ${operationsDuration(entry.durationMs)}`))} +
    `).join('') || '
    No audit recordsNo records match these filters.
    '; +} + +async function loadOperationsAudit() { + const [result, health] = await Promise.all([window.pork.auditQuery(operationsAuditFilters()), window.pork.auditHealth()]); + renderOperationsAudit(result, health); + return result; +} + +function renderOperationsCompatibility(data) { + const profile = data?.profile || {}; + $('operations-profile-firmware').value = profile.firmware || ''; + $('operations-profile-hen').value = profile.hen || ''; + $('operations-profile-hen-version').value = profile.henVersion || ''; + $('operations-profile-kstuff').value = profile.kstuffVersion || ''; + $('operations-profile-loader').value = profile.loader || ''; + $('operations-profile-notes').value = profile.notes || ''; + $('operations-compatibility-checks').innerHTML = (data?.checks || []).map(check => `
    ${escHtml(check.label)}${escHtml(check.detail)}
    ${operationsStatusBadge(check.status)}
    `).join('') || '

    No checks available.

    '; +} + +function renderReplicationStatus(status) { + const targetState = status.available && status.writable ? 'Available and writable' : status.error || 'Unavailable'; + const free = status.storage ? `${operationsBytes(status.storage.availableBytes)} free` : 'free space unknown'; + const protection = status.protection || {}; + const rpo = protection.effectiveRpoSeconds == null ? 'not established' : protection.effectiveRpoSeconds < 3600 + ? `${Math.ceil(protection.effectiveRpoSeconds / 60)} min` + : `${Math.round(protection.effectiveRpoSeconds / 360) / 10} h`; + const rto = protection.lastRestoreDrillDurationMs == null ? 'not measured' : `${Math.max(1, Math.round(protection.lastRestoreDrillDurationMs / 1000))} s drill`; + $('operations-replication-status').innerHTML = ` +
    ${escHtml(targetState)}${escHtml(status.target || '/replication')}${escHtml(free)}${status.running ? ' · replication running' : ''}
    +
    +
    ${protection.threeTwoOneSatisfied ? 'Met' : 'Not met'}3-2-1 status
    +
    ${escHtml(rpo)}Effective RPO
    +
    ${escHtml(rto)}Observed restore drill
    +
    ${protection.immutableObserved ? 'Yes' : 'No'}Immutable target
    +
    +
    Target contract: ${escHtml(status.targetContract?.type || 'unknown')}

    Atomic: ${status.targetContract?.atomicPublish ? 'yes' : 'unknown'} · hashes: ${status.targetContract?.hashVerified ? 'yes' : 'unknown'} · immutable: ${status.targetContract?.immutable ? 'yes' : 'no'} · off-host: ${escHtml(status.targetContract?.offHost || 'unknown')}

    ${protection.blockers?.length ? `
      ${protection.blockers.map(item => `
    • ${escHtml(item)}
    • `).join('')}
    ` : ''}
    `; + $('operations-replication-retention-enabled').checked = status.retention?.enabled !== false; + $('operations-replication-retention-count').value = status.retention?.maxSnapshots || 14; + $('operations-replication-retention-days').value = status.retention?.maxAgeDays || 180; + $('operations-replication-run').disabled = !status.available || !status.writable || status.running; + $('operations-replication-list').innerHTML = (status.snapshots || []).map(snapshot => snapshot.invalid + ? `
    Invalid snapshot${escHtml(snapshot.id)}${escHtml(snapshot.error)}
    ` + : `
    ${escHtml(snapshot.label || 'Replication snapshot')}${operationsDate(snapshot.completedAt || snapshot.createdAt)} · ${Number(snapshot.files || 0)} files · ${operationsBytes(snapshot.bytes)}${snapshot.verifiedAt ? ` · verified ${operationsDate(snapshot.verifiedAt)}` : ''}${snapshot.lastRestoreDrillAt ? ` · restore drill ${operationsDate(snapshot.lastRestoreDrillAt)}` : ''}${escHtml(snapshot.id)}
    `).join('') || '

    No replication snapshots yet.

    '; +} + +function renderMonitoring(value) { + const metrics = [ + ['Uptime', `${Math.round(Number(value.uptimeSeconds || 0) / 60)} min.`], + ['Memory', operationsBytes(value.memory?.rssBytes)], + ['Games', value.library?.totalGames || 0], + ['Backups', value.library?.totalBackups || 0], + ['FTP', value.ftpConnected ? 'Connected' : 'Offline'], + ['Task failures', value.automation?.recentFailures || 0], + ['Replications', value.replication?.snapshots || 0], + ['API p95', `${Number(value.api?.p95DurationMs || 0)} ms`], + ['Last scan', value.scans?.lastDurationMs == null ? 'not measured' : `${Number(value.scans.lastDurationMs)} ms`], + ['Transfer / 24h', operationsBytes(value.transfers?.bytes)], + ['Cache hit', `${Math.round(Number(value.transport?.staticCacheHitRatio || 0) * 100)}%`], + ['WebSocket disconnects', Number(value.transport?.wsDisconnects || 0)], + ]; + $('operations-monitoring-summary').innerHTML = metrics.map(([label, result]) => `
    ${escHtml(result)}${escHtml(label)}
    `).join(''); +} + +function renderSessions(result) { + $('operations-session-list').innerHTML = (result.sessions || []).map(session => `
    ${session.current ? 'This browser' : escHtml(session.userAgent || 'Unknown browser')}${escHtml(session.ip || 'unknown')} · last active ${operationsDate(session.lastSeenAt)} · expires ${operationsDate(session.expiresAt)}${escHtml(session.id)}
    `).join('') || '

    No active sessions.

    '; +} + +async function loadOperationsReliability({ focus = '' } = {}) { + if (focus === 'security') { + if (!window.pork.authSessions) { + $('operations-session-list').innerHTML = '

    Session management is available only in server mode.

    '; + return; + } + renderSessions(await window.pork.authSessions()); + return; + } + if (!window.pork.backupReplicationStatus || !window.pork.authSessions) { + $('operations-replication-status').innerHTML = '

    Session and replication management are available only in server mode.

    '; + return; + } + const [replication, monitoring, token, sessions] = await Promise.all([ + window.pork.backupReplicationStatus(), window.pork.monitoringSnapshot(), window.pork.monitoringTokenStatus(), window.pork.authSessions(), + ]); + renderReplicationStatus(replication); + renderMonitoring(monitoring); + $('operations-monitoring-token-state').textContent = token.configured + ? window.t('operations.monitoring.tokenActive', 'Metrics token active · endpoint /metrics') + : window.t('operations.monitoring.tokenDisabled', 'Metrics disabled until a token is created'); + renderSessions(sessions); +} + +async function loadOperations({ background = false } = {}) { + const [status, storage, snapshots, compatibility, topology] = await Promise.all([ + window.pork.operationsStatus(), + window.pork.storageStatus().catch(() => null), + window.pork.recoveryList().catch(() => []), + window.pork.compatibilityGet().catch(() => ({ profile: {}, checks: [] })), + window.pork.consoleStorageTopology?.().catch(() => null) || Promise.resolve(null), + ]); + renderOperationsAutomation(status); + renderOperationsStorage(storage); + renderOperationsRecovery(snapshots); + renderOperationsCompatibility(compatibility); + renderStorageTopology(topology); + _operationsLoaded = true; + if (!background) $('operations-health').title = window.tf('operations.status.updated', 'Updated {time}', { + time: new Date().toLocaleTimeString(window.i18n?.current || 'en'), + }); +} + +async function loadOperationsShadowMount() { + const result = await window.pork.shadowMountStatus(); + const config = result.files?.['shadowmount-config'] || {}; + const manual = result.files?.['shadowmount-manual'] || {}; + $('operations-shadow-config').value = config.text || ''; + $('operations-shadow-manual').value = manual.text || ''; + for (const [element, file] of [[$('operations-shadow-config-state'), config], [$('operations-shadow-manual-state'), manual]]) { + element.textContent = file.ok ? 'Loaded' : 'Unavailable'; + element.className = `badge ${file.ok ? 'badge-green' : 'badge-red'}`; + element.title = file.error || ''; + } + renderOperationsShadowMountModel(result.model || { summary: {}, entries: [], unknownStatusLines: [] }); + renderOperationsShadowPreflight(result.preflight); +} + +function renderOperationsShadowPreflight(preflight) { + const target = $('operations-shadow-preflight'); + if (!target) return; + if (!preflight) { + target.innerHTML = 'Configuration preflight unavailable

    Load a reachable config.ini or run the check again while saving.

    '; + return; + } + const counts = preflight.counts || {}; + const headline = !preflight.ok ? 'Save blocked' : preflight.requiresRiskConfirmation ? 'Explicit risk confirmation required' : 'Preflight passed'; + const findings = (preflight.findings || []).map(item => `
  • ${escHtml(item.severity)}${item.line ? ` · line ${Number(item.line)}` : ''}${item.key ? ` · ${escHtml(item.key)}` : ''}: ${escHtml(item.message)}
  • `).join(''); + target.innerHTML = `${escHtml(headline)}

    ${Number(counts.invalid || 0)} invalid · ${Number(counts.critical || 0)} critical · ${Number(counts.warning || 0)} warning(s) · ${Number(counts.unknown || 0)} unknown future key(s)

    ${findings ? `
      ${findings}
    ` : '

    No safety issues in the recognized configuration.

    '}`; +} + +function renderOperationsShadowMountModel(model) { + const summary = model.summary || {}; + const entries = model.entries || []; + const statusBadge = $('operations-shadow-status-state'); + statusBadge.textContent = summary.invalid || summary.unknownStatusLines ? 'review needed' : `${summary.installed || 0} installed`; + statusBadge.className = `badge ${summary.invalid || summary.unknownStatusLines ? 'badge-yellow' : 'badge-green'}`; + $('operations-shadow-summary').innerHTML = [ + ['Active sources', summary.active || 0], + ['Installed', summary.installed || 0], + ['Pending', summary.pending || 0], + ['Deleted', summary.deleted || 0], + ['Experimental', summary.experimental || 0], + ['Library matches', summary.libraryMatches || 0], + ].map(([label, value]) => `
    ${escHtml(value)}${escHtml(label)}
    `).join(''); + + const stateLabel = { installed: 'Installed', pending: 'Pending', deleted: 'Deleted', invalid: 'Invalid' }; + const supportLabel = { recommended: 'Recommended', compatibility: 'Compatibility', experimental: 'Experimental', normal: 'Folder', unknown: 'Unknown' }; + const rows = entries.map(entry => `
    +
    ${escHtml(entry.titleName || entry.titleId || entry.sourceName || 'Unknown source')}${escHtml(entry.titleId || 'no title ID')} · ${escHtml(entry.format?.label || 'Unknown')} · ${escHtml(supportLabel[entry.format?.support] || entry.format?.support || 'Unknown')}${escHtml(entry.sourcePath)}${entry.warnings?.length ? `${entry.warnings.map(escHtml).join(' ')}` : ''}
    + ${escHtml(stateLabel[entry.state] || entry.state)} +
    `); + if (model.unknownStatusLines?.length) rows.push(`
    ${model.unknownStatusLines.length} unknown status line(s)Preserved because the upstream format was not recognized.${escHtml(model.unknownStatusLines.map(item => `line ${item.line}: ${item.text}`).join('\n'))}
    Raw
    `); + $('operations-shadow-entries').innerHTML = rows.length ? rows.join('') : '

    manual.lst and manual.status do not contain game sources yet.

    '; +} + +async function loadOperationsPackages() { + const result = await window.pork.packagesStatus(); + $('operations-package-services').innerHTML = (result.services || []).map(service => `
    ${escHtml(service.label)}Port ${service.port}${service.error ? ` · ${escHtml(service.error)}` : ''}
    ${service.reachable ? 'Online' : 'Offline'}
    `).join(''); + return result; +} + +function showOperationsJson(element, value) { + element.hidden = false; + element.textContent = typeof value === 'string' ? value : JSON.stringify(value, null, 2); +} + +function klogFilters() { + return { severity: $('operations-klog-severity').value, search: $('operations-klog-search').value.trim(), limit: 200 }; +} + +function renderKlogStatus(status) { + const badge = $('operations-klog-state'); + badge.textContent = status.running ? status.connected ? 'Capture active' : 'Connecting…' : 'Stopped'; + badge.className = `badge ${status.running ? status.connected ? 'badge-green' : 'badge-yellow' : ''}`; + $('operations-klog-start').disabled = status.running || !status.available; + $('operations-klog-stop').disabled = !status.running; + $('operations-klog-summary').innerHTML = [ + ['Port', status.port || 3232], ['Status', status.reason || 'unknown'], ['Lines', status.lines || 0], + ['Received', operationsBytes(status.bytes)], ['Write contract', status.zeroWriteContract ? '0 bytes' : 'unknown'], ['Payload started', status.payloadLaunchAttempted ? 'yes' : 'no'], + ].map(([label, value]) => `
    ${escHtml(value)}${escHtml(label)}
    `).join(''); +} + +function renderKlogEntries(result) { + $('operations-klog-list').innerHTML = (result.entries || []).map(entry => `
    +
    ${escHtml(entry.severity.toUpperCase())}${operationsDate(entry.time)}
    + ${operationsStatusBadge(entry.severity === 'error' ? 'failed' : entry.severity === 'warn' ? 'warning' : 'completed')} + ${escHtml(String(entry.sessionId || '').slice(0, 8))} + ${escHtml(entry.line)} +
    `).join('') || '

    No kernel log records match these filters.

    '; +} + +async function loadKlog() { + const [status, result] = await Promise.all([window.pork.klogStatus(), window.pork.klogQuery(klogFilters())]); + renderKlogStatus(status); + renderKlogEntries(result); +} + +document.querySelectorAll('.operations-tab').forEach(button => button.addEventListener('click', async () => { + const tab = button.dataset.operationsTab; + document.querySelectorAll('.operations-tab').forEach(item => item.classList.toggle('active', item === button)); + document.querySelectorAll('.operations-panel').forEach(panel => { + const active = panel.dataset.operationsPanel === tab; + panel.classList.toggle('active', active); + panel.hidden = !active; + }); + try { + if (tab === 'shadowmount') await loadOperationsShadowMount(); + if (tab === 'storage') await loadStorageTopology(); + if (tab === 'packages') await loadOperationsPackages(); + if (tab === 'reliability') await loadOperationsReliability(); + if (tab === 'audit') await loadOperationsAudit(); + if (tab === 'diagnostics') await loadKlog(); + } catch (error) { showToast(error.message, 'error'); } +})); + +$('operations-refresh')?.addEventListener('click', event => operationsAction('refresh', event.currentTarget, () => loadOperations()).catch(error => showToast(error.message, 'error'))); + +$('operations-task-list')?.addEventListener('click', async event => { + const row = event.target.closest('.operations-task'); + if (!row) return; + const taskId = row.dataset.taskId; + try { + if (event.target.closest('.operations-task-run')) { + await operationsAction(`run-${taskId}`, event.target.closest('button'), () => window.pork.operationsRunTask(taskId)); + showToast('Task completed.', 'ok'); + await loadOperations(); + } + if (event.target.closest('.operations-task-save')) { + await operationsAction(`save-${taskId}`, event.target.closest('button'), () => window.pork.operationsConfigureTask(taskId, { + enabled: row.querySelector('.operations-task-enabled').checked, + intervalMinutes: Number(row.querySelector('.operations-task-interval').value), + })); + showToast('Schedule saved.', 'ok'); + await loadOperations(); + } + } catch (error) { showToast(error.message, 'error'); } +}); + +$('operations-storage-run')?.addEventListener('click', async event => { + try { + const report = await operationsAction('storage', event.currentTarget, () => window.pork.storageAudit()); + renderOperationsStorage(report); + showToast('Storage audit completed. Nothing was deleted.', 'ok'); + } catch (error) { showToast(error.message, 'error'); } +}); + +$('operations-storage-topology-refresh')?.addEventListener('click', event => operationsAction('storage-topology', event.currentTarget, loadStorageTopology).catch(error => showToast(error.message, 'error'))); + +$('operations-recovery-create')?.addEventListener('click', async event => { + const label = await showPrompt('Name for this restore point:', 'Manual restore point'); + if (label == null) return; + try { + await operationsAction('recovery-create', event.currentTarget, () => window.pork.recoveryCreate({ label, includeRemote: true })); + renderOperationsRecovery(await window.pork.recoveryList()); + showToast('Restore point saved safely.', 'ok'); + } catch (error) { showToast(error.message, 'error'); } +}); + +$('operations-recovery-import')?.addEventListener('click', async event => { + try { + const result = await operationsAction('recovery-import', event.currentTarget, () => window.pork.recoveryImport()); + if (!result) return; + renderOperationsRecovery(await window.pork.recoveryList()); + showToast(`Archive imported as ${result.id} and verified with SHA-256. Nothing was restored.`, 'ok', 9000); + } catch (error) { showToast(error.message, 'error', 10000); } +}); + +$('operations-recovery-list')?.addEventListener('click', async event => { + const row = event.target.closest('.operations-recovery-item'); + if (!row) return; + const id = row.dataset.snapshotId; + try { + if (event.target.closest('.operations-recovery-verify')) { + const verified = await operationsAction(`verify-${id}`, event.target.closest('button'), () => window.pork.recoveryVerify(id)); + showToast(`${verified.files.length} files verified (${verified.integrity}). Nothing was restored.`, 'ok'); + } + if (event.target.closest('.operations-recovery-export')) await operationsAction(`export-${id}`, event.target.closest('button'), () => window.pork.recoveryExport(id)); + if (event.target.closest('.operations-recovery-restore')) { + if (!await showExactConfirmation('This restores the database, settings and available PS5 configuration. A new safety recovery point is created first.', 'RESTORE')) return; + await operationsAction(`restore-${id}`, event.target.closest('button'), () => window.pork.recoveryRestore(id, true)); + showToast('Restore completed. Refresh the page if settings are not immediately visible.', 'ok', 8000); + await loadOperations(); + } + if (event.target.closest('.operations-recovery-delete')) { + if (!await showExactConfirmation('Permanently delete this recovery point?', 'DELETE')) return; + await operationsAction(`delete-${id}`, event.target.closest('button'), () => window.pork.recoveryDelete(id)); + renderOperationsRecovery(await window.pork.recoveryList()); + } + } catch (error) { showToast(error.message, 'error'); } +}); + +$('operations-replication-refresh')?.addEventListener('click', event => operationsAction('replication-refresh', event.currentTarget, loadOperationsReliability).catch(error => showToast(error.message, 'error'))); +$('operations-replication-run')?.addEventListener('click', async event => { + const includeGameBackups = $('operations-replication-games').checked; + const warning = includeGameBackups ? '\n\nFull game backups are selected and may use a large amount of storage.' : ''; + if (!await showConfirm(`Create a new hash-verified snapshot on the secondary backup target? Nothing is removed from the source.${warning}`)) return; + try { + const label = `Manual replication ${new Date().toLocaleString(window.i18n?.current || 'en')}`; + await operationsAction('replication-run', event.currentTarget, () => window.pork.backupReplicationRun({ includeGameBackups, label })); + await loadOperationsReliability(); + showToast('Replication completed and every copied file was verified.', 'ok', 8000); + } catch (error) { showToast(error.message, 'error', 10000); } +}); +$('operations-replication-list')?.addEventListener('click', async event => { + const row = event.target.closest('[data-replication-id]'); + if (!row) return; + try { + const button = event.target.closest('button'); + if (button?.classList.contains('operations-replication-verify')) { + await operationsAction(`replication-verify-${row.dataset.replicationId}`, button, () => window.pork.backupReplicationVerify(row.dataset.replicationId)); + showToast('Snapshot fully verified with SHA-256.', 'ok'); + } else if (button?.classList.contains('operations-replication-drill')) { + if (!await showConfirm('Copy every snapshot file to a temporary local destination, verify its hash, then remove the temporary copies? The PS5 is not modified.')) return; + const result = await operationsAction(`replication-drill-${row.dataset.replicationId}`, button, () => window.pork.backupReplicationRestoreDrill(row.dataset.replicationId)); + showToast(`Restore drill succeeded: ${result.files} files, PS5 unchanged.`, 'ok', 8000); + } else return; + await loadOperationsReliability(); + } catch (error) { showToast(error.message, 'error', 10000); } +}); +$('operations-replication-retention')?.addEventListener('submit', async event => { + event.preventDefault(); + try { + const preview = await operationsAction('replication-retention', event.submitter, () => window.pork.backupReplicationRetentionSave({ enabled: $('operations-replication-retention-enabled').checked, maxSnapshots: Number($('operations-replication-retention-count').value), maxAgeDays: Number($('operations-replication-retention-days').value) })); + showToast(`Retention saved. ${preview.removable?.length || 0} snapshot(s) will be eligible after the next successful replication.`, 'ok', 9000); + await loadOperationsReliability(); + } catch (error) { showToast(error.message, 'error'); } +}); +$('operations-monitoring-refresh')?.addEventListener('click', event => operationsAction('monitoring-refresh', event.currentTarget, async () => renderMonitoring(await window.pork.monitoringSnapshot())).catch(error => showToast(error.message, 'error'))); +$('operations-monitoring-token')?.addEventListener('click', async event => { + if (!await showConfirm('Create a new metrics token? An existing token becomes invalid immediately.')) return; + try { + const result = await operationsAction('metrics-token', event.currentTarget, () => window.pork.monitoringTokenRotate()); + $('operations-monitoring-token-state').textContent = 'New token active. Save it now; Chimera will not show it again.'; + showOperationsJson($('operations-monitoring-token-output'), `Endpoint: ${location.origin}${result.endpoint}\nAuthorization: Bearer ${result.token}\n\nThis token is shown only once.`); + } catch (error) { showToast(error.message, 'error'); } +}); +$('operations-sessions-refresh')?.addEventListener('click', event => operationsAction('sessions-refresh', event.currentTarget, async () => renderSessions(await window.pork.authSessions())).catch(error => showToast(error.message, 'error'))); +$('operations-session-list')?.addEventListener('click', async event => { + const row = event.target.closest('[data-session-id]'); + if (!row || !event.target.closest('.operations-session-revoke') || !await showExactConfirmation('Revoke this session immediately?', 'REVOKE')) return; + try { + const result = await window.pork.authSessionRevoke(row.dataset.sessionId); + if (result.current) { location.replace('/server-login.html'); return; } + renderSessions(await window.pork.authSessions()); + showToast('Session revoked.', 'ok'); + } catch (error) { showToast(error.message, 'error'); } +}); +$('operations-password-form')?.addEventListener('submit', async event => { + event.preventDefault(); + const currentPassword = $('operations-password-current').value; + const newPassword = $('operations-password-new').value; + if (newPassword !== $('operations-password-repeat').value) { showToast('The new passwords do not match.', 'error'); return; } + if (!await showConfirm('Change the password and revoke all other active sessions?')) return; + try { + const result = await operationsAction('password-change', event.submitter, () => window.pork.authPasswordChange(currentPassword, newPassword)); + event.currentTarget.reset(); + showToast(`Password changed; ${result.otherSessionsRevoked} other session(s) revoked.`, 'ok', 8000); + renderSessions(await window.pork.authSessions()); + } catch (error) { showToast(error.message, 'error'); } +}); + +$('operations-profile-save')?.addEventListener('click', async event => { + try { + const result = await operationsAction('profile', event.currentTarget, () => window.pork.compatibilitySet({ + firmware: $('operations-profile-firmware').value, + hen: $('operations-profile-hen').value, + henVersion: $('operations-profile-hen-version').value, + kstuffVersion: $('operations-profile-kstuff').value, + loader: $('operations-profile-loader').value, + notes: $('operations-profile-notes').value, + })); + renderOperationsCompatibility(result); + showToast('PS5 profile saved.', 'ok'); + } catch (error) { showToast(error.message, 'error'); } +}); + +$('operations-shadow-reload')?.addEventListener('click', event => operationsAction('shadow-load', event.currentTarget, loadOperationsShadowMount).catch(error => showToast(error.message, 'error'))); +$('operations-shadow-propose')?.addEventListener('click', async event => { + try { + const proposal = await operationsAction('shadow-proposal', event.currentTarget, () => window.pork.shadowMountProposal()); + $('operations-shadow-manual').value = proposal.proposedManual; + $('operations-shadow-preflight').innerHTML = `Library proposal · ${proposal.additions.length} addition(s)

    ${proposal.unchanged.length} unchanged · ${proposal.unresolved.length} unresolved. Nothing was written to the PS5.

    ${proposal.unresolved.length ? `
      ${proposal.unresolved.slice(0, 20).map(item => `
    • ${escHtml(item.gameId)} · ${escHtml(item.reason)}
    • `).join('')}
    ` : ''}`; + showToast(proposal.additions.length ? 'ShadowMount proposal loaded for review; save is still separate.' : 'No new PS5-visible paths were found.', proposal.unresolved.length ? 'info' : 'ok', 9000); + } catch (error) { showToast(error.message, 'error', 10000); } +}); +$('operations-shadow-save')?.addEventListener('click', async event => { + try { + const config = $('operations-shadow-config').value; + const manual = $('operations-shadow-manual').value; + const preflight = await operationsAction('shadow-preflight', event.currentTarget, () => window.pork.shadowMountPreflight(config)); + renderOperationsShadowPreflight(preflight); + if (!preflight.ok) throw new Error('The configuration contains invalid values. Resolve the preflight findings before saving.'); + let confirmation = null; + if (preflight.requiresRiskConfirmation) { + confirmation = await showExactConfirmation('This configuration activates critical ShadowMount options. Review the preflight carefully.', preflight.confirmationPhrase); + if (!confirmation) return; + } else if (!await showConfirm('Save the ShadowMount configuration? Chimera creates a recovery point first.')) return; + const result = await operationsAction('shadow-save', event.currentTarget, () => window.pork.shadowMountSave({ config, manual, confirmation })); + showToast(`ShadowMount saved. Safety restore point: ${result.snapshotId}`, 'ok', 8000); + await loadOperationsShadowMount(); + } catch (error) { showToast(error.message, 'error'); } +}); +$('operations-shadow-logs')?.addEventListener('click', async event => { + try { + const result = await operationsAction('shadow-logs', event.currentTarget, () => window.pork.shadowMountLogs()); + if (!result.ok) throw new Error(result.error); + showOperationsJson($('operations-shadow-output'), result.text || 'debug.log is empty.'); + } catch (error) { showToast(error.message, 'error'); } +}); + +$('operations-package-probe')?.addEventListener('click', event => operationsAction('package-probe', event.currentTarget, loadOperationsPackages).catch(error => showToast(error.message, 'error'))); +$('operations-package-list')?.addEventListener('click', async event => { + try { showOperationsJson($('operations-package-output'), await operationsAction('package-list', event.currentTarget, () => window.pork.packagesList())); } + catch (error) { showToast(error.message, 'error'); } +}); +$('operations-package-progress')?.addEventListener('click', async event => { + try { showOperationsJson($('operations-package-output'), await operationsAction('package-progress', event.currentTarget, () => window.pork.packagesProgress())); } + catch (error) { showToast(error.message, 'error'); } +}); +$('operations-package-install')?.addEventListener('click', async event => { + const url = $('operations-package-url').value.trim(); + if (!url || !await showConfirm(`Send this HTTPS URL to etaHEN DPI?\n\n${url}`)) return; + try { + const result = await operationsAction('package-install', event.currentTarget, () => window.pork.packagesInstallUrl(url)); + showOperationsJson($('operations-package-output'), result); + showToast('Package URL sent to etaHEN DPI.', 'ok'); + } catch (error) { showToast(error.message, 'error'); } +}); + +$('operations-diagnostics-run')?.addEventListener('click', async event => { + try { + const result = await operationsAction('diagnostics', event.currentTarget, () => window.pork.diagnosticsSummary()); + const services = result.packages?.services || []; + const build = result.chimera?.build || {}; + $('operations-diagnostics-summary').innerHTML = [ + ['Chimera', result.chimera?.version || '—'], + ['Build', build.id || 'development'], + ['Revision', String(build.revision || 'development').slice(0, 12)], + ['Built', build.builtAt ? operationsDate(build.builtAt) : '—'], + ['Uptime', `${Math.round(Number(result.chimera?.uptimeSeconds || 0) / 60)} min.`], + ['FTP', result.ps5?.ftpConnected ? 'Connected' : result.ps5?.ftpReconnectAvailable ? 'Reconnect available' : 'Not connected'], + ['PS5 services', `${services.filter(item => item.reachable).length}/${services.length} online`], + ].map(([label, value]) => `
    ${escHtml(value)}${escHtml(label)}
    `).join(''); + showOperationsJson($('operations-diagnostics-log'), result.recentLogs || []); + } catch (error) { showToast(error.message, 'error'); } +}); +$('operations-diagnostics-export')?.addEventListener('click', event => operationsAction('diagnostics-export', event.currentTarget, () => window.pork.diagnosticsExport()).then(() => showToast('Geschoonde supportbundel aangemaakt.', 'ok')).catch(error => showToast(error.message, 'error'))); + +$('operations-audit-filter')?.addEventListener('submit', event => { + event.preventDefault(); + operationsAction('audit-filter', event.submitter, loadOperationsAudit).catch(error => showToast(error.message, 'error')); +}); +$('operations-audit-refresh')?.addEventListener('click', event => operationsAction('audit-refresh', event.currentTarget, loadOperationsAudit).catch(error => showToast(error.message, 'error'))); +$('operations-audit-export')?.addEventListener('click', event => operationsAction('audit-export', event.currentTarget, () => window.pork.auditExport(operationsAuditFilters())).then(() => showToast('Gefilterde auditexport aangemaakt.', 'ok')).catch(error => showToast(error.message, 'error'))); + +$('operations-klog-start')?.addEventListener('click', async event => { + try { + const status = await operationsAction('klog-start', event.currentTarget, () => window.pork.klogStart({ durationSeconds: Number($('operations-klog-duration').value) })); + renderKlogStatus(status); + showToast('Passive capture started; Chimera writes nothing to the PS5.', 'ok'); + } catch (error) { showToast(error.message, 'error'); } +}); +$('operations-klog-stop')?.addEventListener('click', async event => { + try { + renderKlogStatus(await operationsAction('klog-stop', event.currentTarget, () => window.pork.klogStop())); + await loadKlog(); + } catch (error) { showToast(error.message, 'error'); } +}); +$('operations-klog-refresh')?.addEventListener('click', event => operationsAction('klog-refresh', event.currentTarget, loadKlog).catch(error => showToast(error.message, 'error'))); +$('operations-klog-export')?.addEventListener('click', event => operationsAction('klog-export', event.currentTarget, () => window.pork.klogExport(klogFilters())).then(() => showToast('Geschoonde kernellogexport aangemaakt.', 'ok')).catch(error => showToast(error.message, 'error'))); + +window.pork?.on?.('operations:task-update', () => { + if (document.querySelector('#page-operations.active')) loadOperations().catch(() => {}); +}); +window.pork?.on?.('klog:status', status => renderKlogStatus(status)); +window.addEventListener('chimera:language-changed', () => { + if (!document.querySelector('#page-operations.active [data-operations-panel="storage"]:not([hidden])')) return; + renderOperationsStorage(_operationsStorageReport); + if (_operationsStorageTopology) renderStorageTopology(_operationsStorageTopology); +}); diff --git a/renderer/modules/packages.js b/renderer/modules/packages.js new file mode 100644 index 0000000..f8370b1 --- /dev/null +++ b/renderer/modules/packages.js @@ -0,0 +1,29 @@ +'use strict'; +(function () { + let catalog = { sets: [], stats: {} }; + const esc = value => String(value ?? '').replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]); + const tr = (key, fallback) => window.t?.(key, fallback) || fallback; + if (!document.querySelector('link[data-package-library-style]')) { + const link = document.createElement('link'); + Object.assign(link, { rel: 'stylesheet', href: 'styles/package-library.css' }); + link.dataset.packageLibraryStyle = 'true'; + document.head.appendChild(link); + } + const componentHtml = component => `
  • ${esc(component.type)}${esc(component.componentId)} ${tr(`packageLibrary.${component.required ? 'required' : 'optional'}`, component.required ? 'required' : 'optional')}
    v${esc(component.version || '?')} ${esc(component.region || '')}${esc(component.sha256)}
  • `; + const recipeHtml = item => `
  • ${esc(item.label || item.recipeKey)}v${esc(item.version)} ${esc(item.state)} ${esc(item.recipeSha256.slice(0, 12))}...
  • `; + function setHtml(set) { + const recipes = set.recipes.length ? set.recipes.map(recipeHtml).join('') : `
  • ${esc(tr('packageLibrary.noRecipes', 'No recipes yet'))}
  • `; + return `
    ${esc(set.title.externalTitleId)}

    ${esc(set.title.name)}

    ${esc(set.label || set.setKey)}${esc(set.platform)} ${esc(set.region || '')} ${esc(set.confidence)} ${esc(set.source)}

    ${esc(tr('packageLibrary.components', 'Components'))} (${set.components.length})

      ${set.components.map(componentHtml).join('')}

    ${esc(tr('packageLibrary.recipes', 'Recipes'))} (${set.recipes.length})

      ${recipes}
    `; + } + function render() { + const host = document.getElementById('package-library-content'); + if (!host) return; + const query = document.getElementById('package-library-search')?.value.trim().toLowerCase(); + const sets = catalog.sets.filter(set => !query || JSON.stringify(set).toLowerCase().includes(query)); + host.innerHTML = sets.length ? `
    ${sets.map(setHtml).join('')}
    ` : `
    ${esc(tr('packageLibrary.empty', 'No package sets match'))}${esc(tr('packageLibrary.emptyDetail', 'Import or adjust the search. Package inventory never implies installation.'))}
    `; + const stats = document.getElementById('package-library-stats'); + if (stats) stats.textContent = [['titles', 'titles'], ['sets', 'sets'], ['components', 'componentsLower'], ['recipes', 'recipesLower']].map(([field, key]) => `${catalog.stats[field] || 0} ${tr(`packageLibrary.${key}`, field)}`).join(' / '); + } + window.loadPackages = async () => { catalog = await window.pork.packageCatalog(); render(); }; + document.getElementById('package-library-search')?.addEventListener('input', render); +})(); diff --git a/renderer/modules/payload.js b/renderer/modules/payload.js new file mode 100644 index 0000000..a8b3ae9 --- /dev/null +++ b/renderer/modules/payload.js @@ -0,0 +1,636 @@ +// ── Payload Manager ─────────────────────────────────────────────────────────── + +function renderPs5PayloadManagerObservability(snapshot) { + const root = $('pldmgr-observability'); + if (!root || !snapshot) return; + const autoload = snapshot.autoload || {}; + const processes = snapshot.processes || {}; + const logs = snapshot.logs || {}; + const stateLabels = { + idle: window.t('payload.state.idle', 'idle'), + countdown: window.t('payload.state.countdown', 'countdown'), + running: window.t('payload.state.running', 'running'), + done: window.t('payload.state.done', 'done'), + unknown: window.t('common.unknown', 'unknown'), + }; + const stateClasses = { done: 'badge-green', running: 'badge-blue', countdown: 'badge-yellow', idle: '', unknown: 'badge-red' }; + const state = autoload.supported ? (autoload.state || 'unknown') : 'unknown'; + const daemonItems = (processes.items || []).filter(item => item.daemon).slice(0, 40); + + root.hidden = false; + $('pldmgr-autoload-metric').textContent = autoload.supported ? `${autoload.done || 0}/${autoload.total || autoload.list?.length || 0}` : 'n/b'; + $('pldmgr-process-metric').textContent = processes.supported ? String(processes.total || 0) : 'n/b'; + $('pldmgr-memory-metric').textContent = processes.supported ? `${Number(processes.memoryMiB || 0).toLocaleString(window.i18n?.current || 'en', { maximumFractionDigits: 1 })} MiB` : 'n/a'; + $('pldmgr-log-metric').textContent = logs.supported ? String(logs.total || 0) : 'n/b'; + $('pldmgr-autoload-state').textContent = stateLabels[state] || state; + $('pldmgr-autoload-state').className = `badge ${stateClasses[state] || ''}`.trim(); + + const detail = []; + if (autoload.supported) { + if (autoload.current) detail.push(window.tf('payload.current', 'Current: {value}', { value: autoload.current })); + if (autoload.remaining > 0) detail.push(window.tf('payload.startsIn', 'starts in {seconds}s', { seconds: autoload.remaining })); + if (autoload.list?.length) detail.push(window.tf('payload.itemsConfigured', '{count} item(s) configured', { count: autoload.list.length })); + if (autoload.scanUsbPayloads) detail.push(window.t('payload.usbScanActive', 'USB scan active')); + } else detail.push(autoload.unavailable || window.t('payload.endpointUnsupported', 'This endpoint is not supported by this Payload Manager version.')); + $('pldmgr-autoload-detail').textContent = detail.join(' · '); + + $('pldmgr-process-list').innerHTML = processes.supported + ? (daemonItems.length ? daemonItems.map(item => `
    ${escHtml(item.name)}PID ${item.pid}
    ${Number(item.memoryMiB || 0).toLocaleString(window.i18n?.current || 'en', { maximumFractionDigits: 1 })} MiB
    `).join('') : `

    ${escHtml(window.t('payload.noDaemons', 'No separate payload daemons detected.'))}

    `) + : `

    ${escHtml(processes.unavailable || window.t('payload.processesUnsupported', 'Processes are unavailable in this Payload Manager version.'))}

    `; + $('pldmgr-log-output').textContent = logs.supported && logs.lines?.length + ? `${logs.truncated ? `[${window.t('payload.latestLogsOnly', 'latest lines only')}]\n` : ''}${logs.lines.join('\n')}` + : (logs.unavailable || window.t('payload.noLogs', 'No log lines yet.')); + $('pldmgr-observability-note').textContent = window.tf( + 'payload.snapshotNote', + 'Read-only snapshot from {time}. Chimera never terminates a process automatically.', + { time: new Date(snapshot.checkedAt || Date.now()).toLocaleTimeString(window.i18n?.current || 'en') }, + ); +} + +function renderPs5PayloadManagerInventory(inventory) { + const list = $('pldmgr-installed-list'); + if (!list) return; + const payloads = (inventory?.payloads || []).slice(0, 200); + list.innerHTML = payloads.length ? payloads.map(item => `
    ${escHtml(item.name || item.filename)}${escHtml(item.version || window.t('payload.unknownVersion', 'unknown version'))}${item.checksumVerified ? ` · ${escHtml(window.t('payload.checksumKnown', 'checksum known'))}` : ''}
    `).join('') : `

    ${escHtml(window.t('payload.noneInstalled', 'No installed payloads reported.'))}

    `; +} + +async function refreshPs5PayloadManagerObservability({ quiet = false } = {}) { + if (!window.pork.pldmgrObservability) return null; + try { + const result = await window.pork.pldmgrObservability(); + renderPs5PayloadManagerObservability(result); + return result; + } catch (error) { + if (!quiet) showToast(error.message, 'error'); + return null; + } +} + +async function connectPs5PayloadManager({ quiet = false, persistPort = false } = {}) { + const status = $('pldmgr-status'); + const version = $('pldmgr-version'); + const frame = $('pldmgr-frame'); + if (!window.pork.pldmgrProbe || !status || !frame) return; + if (!quiet) status.textContent = window.t('payload.connectingPs5', 'Connecting to PS5…'); + try { + const port = Number($('pldmgr-port')?.value || 8084); + if (persistPort) await window.pork.setSettings({ payloadManagerPort: port }); + const settings = await window.pork.getSettings(); + if (!settings.ftpHost) throw new Error(window.t('payload.configureIp', 'Configure the PS5 IP address first.')); + const [result] = await Promise.all([ + window.pork.pldmgrProbe(), + refreshPs5PayloadManagerObservability({ quiet: true }), + ]); + const versionValue = typeof result.version === 'string' + ? result.version + : (result.version?.version || result.version?.menu_version || 'online'); + version.textContent = String(versionValue).replace(/^"|"$/g, '') || 'online'; + const payloadCount = Number(result.inventory?.total || 0); + renderPs5PayloadManagerInventory(result.inventory); + const sourceCount = Number(result.sources?.total || 0); + const repositoryCount = Number(result.repository?.total || 0); + const autoloadRaw = result.autoload?.list || result.autoload?.AUTOLOAD_LIST || ''; + const autoloadCount = Array.isArray(autoloadRaw) + ? autoloadRaw.length + : String(autoloadRaw || '').split(',').filter(Boolean).length; + status.textContent = window.tf( + 'payload.connectedSummary', + 'Connected · {payloads} installed · {autoload} autoload · {sources} sources · {available} available', + { payloads: payloadCount, autoload: autoloadCount, sources: sourceCount, available: repositoryCount }, + ); + const embedUrl = await window.pork.pldmgrEmbedUrl(); + frame.dataset.url = embedUrl; + if (!quiet || frame.dataset.userOpened === 'true') { + frame.src = embedUrl; + frame.hidden = false; + } else { + frame.hidden = true; + } + } catch (error) { + version.textContent = 'offline'; + frame.hidden = true; + if ($('pldmgr-observability')) $('pldmgr-observability').hidden = true; + status.textContent = `${window.t('payload.unreachable', 'Unavailable')}: ${error.message}`; + } +} + +$('btn-pldmgr-refresh')?.addEventListener('click', () => connectPs5PayloadManager()); +$('btn-pldmgr-logs-refresh')?.addEventListener('click', () => refreshPs5PayloadManagerObservability()); +$('pldmgr-installed-list')?.addEventListener('click', async event => { + const button = event.target.closest('.pldmgr-safe-launch'); + if (!button) return; + const payloadPath = button.dataset.payloadPath; + button.disabled = true; + try { + const preflight = await window.pork.pldmgrLaunchPreflight(payloadPath); + let confirmConflict = false; + if (preflight.conflict) { + const conflictConfirmation = await showExactConfirmation( + `${preflight.warning}\n\nActive: ${preflight.active.map(item => `${item.name} (PID ${item.pid})`).join(', ')}\n\nA duplicate instance can make the console unstable.`, + 'START PAYLOAD', + ); + if (!conflictConfirmation) return; + confirmConflict = true; + } else if (!await showExactConfirmation(`Start ${preflight.selected} on the PS5?`, 'START PAYLOAD')) return; + await window.pork.pldmgrLaunch(payloadPath, confirmConflict, 'START PAYLOAD'); + showToast(`${preflight.selected} was sent to Payload Manager.`, 'ok'); + setTimeout(() => refreshPs5PayloadManagerObservability({ quiet: true }), 1_000); + } catch (error) { showToast(error.message, 'error'); } + finally { button.disabled = false; } +}); +$('pldmgr-port')?.addEventListener('change', () => connectPs5PayloadManager({ persistPort: true })); +$('btn-pldmgr-open')?.addEventListener('click', async () => { + try { + const frame = $('pldmgr-frame'); + const url = frame?.dataset.url || await window.pork.pldmgrEmbedUrl(); + if (window.pork.isWebMode && String(url).startsWith('/')) { + frame.dataset.userOpened = 'true'; + frame.src = url; + frame.hidden = false; + frame.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } else await window.pork.openShell(url); + } catch (error) { setStatus(error.message, 'error'); } +}); + +setInterval(() => { + const page = $('page-jailbreak'); + if (page?.classList.contains('active') && $('pldmgr-version')?.textContent !== 'offline') { + refreshPs5PayloadManagerObservability({ quiet: true }); + } +}, 15_000); + +async function loadPs5ServiceMatrix() { + const grid = $('service-matrix-grid'); + const summary = $('service-matrix-summary'); + const button = $('btn-service-matrix-refresh'); + if (!grid || !window.pork.ps5ServiceMatrix) return; + if (button) button.disabled = true; + summary.textContent = 'controleren…'; + try { + const result = await window.pork.ps5ServiceMatrix(); + summary.textContent = `${result.online}/${result.total} online`; + grid.innerHTML = result.services.map(service => ` +
    +
    + + ${escHtml(service.name)} + :${service.port}${service.online && service.latencyMs != null ? ` · ${service.latencyMs} ms` : ''} +
    +

    ${service.online ? `${escHtml(service.section)} is reachable.` : escHtml(service.hint)}

    +
    + `).join(''); + } catch (error) { + summary.textContent = 'check failed'; + grid.innerHTML = `

    ${escHtml(error.message)}

    `; + } finally { if (button) button.disabled = false; } +} + +$('btn-service-matrix-refresh')?.addEventListener('click', loadPs5ServiceMatrix); + +async function renderSettingsPayloadSources() { + const sources = await window.pork.payloadSourcesList(); + const cont = $('settings-payload-sources'); + if (!sources.length) { + cont.innerHTML = '

    No sources yet. Add one below.

    '; + return; + } + const expertMode = document.body.classList.contains('chimera-expert-mode'); + const visibleSources = sources.filter(source => + expertMode || payloadCatalogGroup({ source, files: [] }) !== 'expert' + ); + const hiddenCount = sources.length - visibleSources.length; + const hiddenNotice = hiddenCount > 0 + ? `

    ${escHtml(window.tf( + 'payload.settings.expertHidden', + '{count} expert source(s) hidden. Enable Expert tools to manage them.', + { count: hiddenCount }, + ))}

    ` + : ''; + cont.innerHTML = hiddenNotice + visibleSources.map(s => ` +
    + + ${escHtml(s.name)} + ${escHtml(s.github_url)} + ${s.latest_tag ? `${escHtml(s.latest_tag)}` : ''} + ${s.built_in ? `${escHtml(window.t('payload.builtIn', 'built-in'))}` : ''} +
    + `).join(''); +} + +window.addEventListener('chimera:expert-mode-changed', () => { + if ($('page-settings')?.classList.contains('active')) renderSettingsPayloadSources().catch(error => showToast(error.message, 'error')); +}); + +function payloadStatusBadge(file) { + if (file.local_hash && file.local_version && file.version && file.local_version !== file.version) { + return `↓ ${escHtml(window.t('payload.status.update', 'Update available'))}`; + } + if (file.local_hash && file.remote_hash) { + if (file.local_hash === file.remote_hash) return `✓ ${escHtml(window.t('payload.status.current', 'Up to date'))}`; + return `↓ ${escHtml(window.t('payload.status.update', 'Update available'))}`; + } + if (file.local_hash) return `${escHtml(window.t('payload.status.downloadedUnverified', 'Downloaded (no checksum)'))}`; + if (file.remote_hash) return `${escHtml(window.t('payload.status.notDownloaded', 'Not downloaded'))}`; + return '—'; +} + +function payloadActionBtn(file, source_id) { + const upToDate = !!(file.local_hash && ( + (file.remote_hash && file.local_hash === file.remote_hash) || + (!file.remote_hash && file.local_version && file.version && file.local_version === file.version) + )); + const hasUpdate = !!(file.local_hash && ( + (file.remote_hash && file.local_hash !== file.remote_hash) || + (file.local_version && file.version && file.local_version !== file.version) + )); + + const dlLabel = hasUpdate + ? `↓ ${escHtml(window.t('payload.action.update', 'Update'))}` + : `↓ ${escHtml(window.t('payload.action.download', 'Download'))}`; + const dlCls = hasUpdate ? 'btn btn-sm btn-teal btn-payload-download' : 'btn btn-sm btn-payload-download'; + const dlBtn = !upToDate + ? `` + : ''; + + const pushBtn = file.local_path + ? `` + : ''; + + return (dlBtn || pushBtn) + ? `${dlBtn}${pushBtn}` + : ''; +} + +function payloadHasUpdate(file) { + return Boolean(file.local_hash && ( + (file.remote_hash && file.local_hash !== file.remote_hash) || + (file.local_version && file.version && file.local_version !== file.version) + )); +} + +function payloadCatalogGroup({ source, files }) { + const fingerprint = `${source.name || ''} ${source.category || ''} ${source.description || ''} ${(files || []).map(file => file.asset_name).join(' ')}`.toLowerCase(); + const expertCategory = ['kernel', 'debug', 'linux'].includes(String(source.category || '').toLowerCase()); + if (expertCategory || /(kernel|kstuff|kpatch|exploit|debugger|gdb|elf\s*(?:ldr|loader)|binloader|shell(?:code)?|jailbreak|linux\s+loader)/.test(fingerprint)) return 'expert'; + if ((files || []).some(payloadHasUpdate)) return 'updates'; + if (source.trust === 'custom' || /(experimental|prototype|research|legacy)/.test(fingerprint)) return 'experimental'; + if (source.trust === 'official' && (files || []).some(file => file.remote_hash)) return 'recommended'; + return 'compatible'; +} + +const PAYLOAD_GROUPS = [ + { id: 'updates', label: 'Updates', detail: 'Downloaded payloads with a newer or changed upstream asset.' }, + { id: 'recommended', label: 'Recommended', detail: 'Reviewed official sources with a published checksum.' }, + { id: 'compatible', label: 'Compatible', detail: 'Normal payloads that still require a manual compatibility check.' }, + { id: 'experimental', label: 'Experimental', detail: 'Custom, research or incomplete sources. Review before downloading.' }, + { id: 'expert', label: 'Expert', detail: 'Low-level payloads that can affect console stability.' }, +]; + +function payloadSourceMarkup({ source, files, error }, forceOpen = false) { + const usableFiles = (files || []).filter(file => + /\.(bin|elf|js)$/i.test(file.asset_name) && + !/sha256|checksums|sha2|sums/i.test(file.asset_name) + ); + const tag = source.latest_tag ? `${escHtml(source.latest_tag)}` : ''; + const trust = escHtml(source.trust || 'custom'); + const checked = source.last_checked + ? `${escHtml(window.tf('payload.checkedAt', 'checked {date}', { date: new Date(source.last_checked).toLocaleString() }))}` + : ''; + const errRow = error ? `

    ${escHtml(error)}

    ` : ''; + const rows = usableFiles.map(file => ` + + ${escHtml(file.asset_name)} + ${payloadStatusBadge(file)} + ${file.remote_hash ? file.remote_hash.slice(0, 12) + '…' : '—'} + ${escHtml(file.version || '—')} · ${file.asset_size ? fmt(file.asset_size) : escHtml(window.t('payload.unknownSize', 'unknown size'))} + ${payloadActionBtn(file, source.id)} + + `).join(''); + return ` +
    + + ${escHtml(source.name)} + ${usableFiles.length} + ${tag} ${checked} + + ${trust} + ${escHtml(source.category || 'other')} + + +
    + ${source.description ? `

    ${escHtml(source.description)}

    ` : ''} + ${errRow} + ${rows ? `
    ${rows}
    ` : `

    ${escHtml(window.t('payload.noAssets', 'No assets found.'))}

    `} +
    +
    `; +} + +function renderPayloadSourceCheckList(results) { + const cont = $('payload-sources-check-list'); + if (!results || !results.length) { + cont.innerHTML = `
    ⌁${escHtml(window.t('payload.empty.title', 'No payload sources match'))}${escHtml(window.t('payload.empty.detail', 'Adjust the filters or add a source in Settings.'))}
    `; + return; + } + const queryActive = Boolean(String($('payload-catalog-search')?.value || '').trim()); + const grouped = Object.groupBy + ? Object.groupBy(results, payloadCatalogGroup) + : results.reduce((groups, item) => { + const id = payloadCatalogGroup(item); + (groups[id] ||= []).push(item); + return groups; + }, {}); + cont.innerHTML = PAYLOAD_GROUPS.map(group => { + const items = grouped[group.id] || []; + if (!items.length) return ''; + const label = window.t(`payload.groups.${group.id}.label`, group.label); + const detail = window.t(`payload.groups.${group.id}.detail`, group.detail); + const risk = group.id === 'expert' || group.id === 'experimental' ? group.id : 'standard'; + return `
    + ${escHtml(label)}${escHtml(detail)}${items.length} +
    ${items.map(item => payloadSourceMarkup(item, queryActive)).join('')}
    +
    `; + }).join(''); +} + +let _payloadCatalog = null; + +function catalogResults(catalog) { + return (catalog.sources || []).map(source => ({ + source, + files: (catalog.files || []).filter(file => file.source_id === source.id), + error: source.last_error || null, + })); +} + +function applyPayloadCatalogFilters() { + if (!_payloadCatalog) return; + const query = String($('payload-catalog-search')?.value || '').trim().toLowerCase(); + const category = String($('payload-catalog-category')?.value || 'all'); + const showExpert = Boolean($('payload-show-expert')?.checked); + const results = catalogResults(_payloadCatalog) + .filter(({ source }) => category === 'all' || source.category === category) + .map(item => ({ + ...item, + files: item.files.filter(file => !query || `${file.asset_name} ${item.source.name} ${item.source.description || ''}`.toLowerCase().includes(query)), + })) + .filter(item => !query || item.files.length || `${item.source.name} ${item.source.description || ''}`.toLowerCase().includes(query)) + .filter(item => showExpert || payloadCatalogGroup(item) !== 'expert'); + renderPayloadSourceCheckList(results); +} + +function renderPayloadCatalog(catalog) { + _payloadCatalog = catalog; + const stats = catalog.stats || {}; + $('payload-stat-assets').textContent = String(stats.available || 0); + $('payload-stat-sources').textContent = String(stats.enabledSources || stats.sources || 0); + $('payload-stat-downloaded').textContent = String(stats.downloaded || 0); + $('payload-stat-updates').textContent = String(stats.updates || 0); + $('payload-stat-verified').textContent = String(stats.verified || 0); + const select = $('payload-catalog-category'); + const selected = select.value || 'all'; + const categories = [...new Set((catalog.sources || []).map(source => source.category || 'other'))].sort(); + select.innerHTML = `` + categories.map(value => ``).join(''); + select.value = categories.includes(selected) ? selected : 'all'; + applyPayloadCatalogFilters(); +} + +async function loadPayloadCatalog() { + const catalog = await window.pork.payloadCatalog(); + renderPayloadCatalog(catalog); + return catalog; +} + +async function renderLocalPayloads() { + let files; + try { files = await window.pork.payloadListLocal(); } catch { files = []; } + const list = $('payload-local-list'); + const empty = $('payload-local-empty'); + if (!files.length) { + list.innerHTML = ''; + empty.hidden = false; + return; + } + empty.hidden = true; + list.innerHTML = files.map(f => { + const ext = (f.name.split('.').pop() || '').toLowerCase(); + const extClass = { elf: 'al-ext-elf', bin: 'al-ext-bin', js: 'al-ext-js' }[ext] || ''; + return `
    + ${escHtml(ext || '?')} + ${escHtml(f.name)} + ${fmt(f.size)} + ${payloadStatusBadge(f)} +
    + + +
    +
    `; + }).join(''); +} + +async function renderPayloadSender() { + let files; + try { files = await window.pork.payloadListLocal(); } catch { files = []; } + const list = $('payload-sender-list'); + const empty = $('payload-sender-empty'); + if (!files.length) { + list.innerHTML = ''; + empty.hidden = false; + return; + } + empty.hidden = true; + const status = $('payload-sender-status'); + if (status) status.textContent = ''; + list.innerHTML = files.map(f => { + const ext = (f.name.split('.').pop() || '').toLowerCase(); + const extClass = { elf: 'al-ext-elf', bin: 'al-ext-bin', js: 'al-ext-js' }[ext] || ''; + return `
    + ${escHtml(ext || '?')} + ${escHtml(f.name)} + ${fmt(f.size)} + +
    `; + }).join(''); +} + +$('btn-payload-check-all').addEventListener('click', async () => { + const btn = $('btn-payload-check-all'); + const status = $('payload-check-status'); + btn.disabled = true; + status.textContent = 'Checking…'; + try { + const results = await window.pork.payloadCheckUpdates(); + await loadPayloadCatalog(); + await renderLocalPayloads(); + status.textContent = `Done — ${results.length} source(s) checked`; + } catch (e) { + status.textContent = `Error: ${e.message}`; + setStatus(`Payload check failed: ${e.message}`, 'error'); + } finally { + btn.disabled = false; + } +}); + +$('payload-catalog-search')?.addEventListener('input', applyPayloadCatalogFilters); +$('payload-catalog-category')?.addEventListener('change', applyPayloadCatalogFilters); +$('payload-show-expert')?.addEventListener('change', event => { + localStorage.setItem('chimera.payload.showExpert', event.target.checked ? '1' : '0'); + applyPayloadCatalogFilters(); +}); +$('btn-payload-update-downloaded')?.addEventListener('click', async () => { + const btn = $('btn-payload-update-downloaded'); + const status = $('payload-check-status'); + btn.disabled = true; + status.textContent = 'Checking and updating local payloads…'; + try { + const result = await window.pork.payloadUpdateDownloaded(); + await loadPayloadCatalog(); + await renderLocalPayloads(); + await renderPayloadSender(); + status.textContent = `${result.checked} checked locally · ${result.updated} updated${result.failed ? ` · ${result.failed} failed` : ''}`; + } catch (error) { + status.textContent = `Update failed: ${error.message}`; + } finally { btn.disabled = false; } +}); + +$('payload-sources-check-list').addEventListener('click', async e => { + const pushBtn = e.target.closest('.btn-payload-push-remote'); + if (pushBtn) { + const local_path = pushBtn.dataset.localPath; + const filename = pushBtn.dataset.filename; + const origText = pushBtn.textContent; + pushBtn.disabled = true; + pushBtn.textContent = 'Queuing…'; + try { + await window.pork.payloadPush({ local_path, filename }); + setStatus(`${filename} queued for FTP transfer to PS5`, 'ok'); + navigate('transfers'); + } catch (err) { + setStatus(`FTP push failed: ${err.message}`, 'error'); + pushBtn.disabled = false; + pushBtn.textContent = origText; + } + return; + } + + const dlBtn = e.target.closest('.btn-payload-download'); + if (!dlBtn) return; + const source_id = Number(dlBtn.dataset.sourceId); + const asset_name = dlBtn.dataset.assetName; + const asset_url = dlBtn.dataset.assetUrl; + const version = dlBtn.dataset.version; + dlBtn.disabled = true; + dlBtn.textContent = 'Downloading…'; + try { + await window.pork.payloadDownload({ source_id, asset_name, asset_url, version }); + setStatus(`Downloaded ${asset_name}`, 'ok'); + await loadPayloadCatalog(); + await renderLocalPayloads(); + await renderPayloadSender(); + } catch (err) { + setStatus(`Download failed: ${err.message}`, 'error'); + dlBtn.disabled = false; + dlBtn.textContent = '↓ Retry'; + } +}); + +$('payload-local-list').addEventListener('click', async e => { + const pushBtn = e.target.closest('.btn-payload-push'); + if (pushBtn) { + const local_path = pushBtn.dataset.localPath; + const filename = pushBtn.dataset.filename; + try { + await window.pork.payloadPush({ local_path, filename }); + setStatus(`${filename} queued for transfer`, 'ok'); + navigate('transfers'); + } catch (e2) { + setStatus(`Push failed: ${e2.message}`, 'error'); + } + return; + } + + const delBtn = e.target.closest('.btn-payload-delete-local'); + if (delBtn) { + const confirmation = await showExactConfirmation(`Delete local file "${delBtn.dataset.localPath}"?`, 'VERWIJDER'); + if (!confirmation) return; + try { + const id = delBtn.dataset.id ? Number(delBtn.dataset.id) : null; + const local_path = delBtn.dataset.localPath; + await window.pork.payloadDeleteLocal({ id, local_path, confirmation }); + await renderLocalPayloads(); + await renderPayloadSender(); + } catch (e2) { + setStatus(`Delete failed: ${e2.message}`, 'error'); + } + } +}); + +$('payload-sender-list').addEventListener('click', async e => { + const btn = e.target.closest('.btn-payload-tcp-send'); + if (!btn) return; + const local_path = btn.dataset.localPath; + const filename = btn.dataset.filename; + const port = parseInt($('payload-sender-port')?.value || '9021', 10); + const status = $('payload-sender-status'); + btn.disabled = true; + btn.textContent = 'Sending\u2026'; + if (status) { status.textContent = `Sending ${filename} on port ${port}\u2026`; status.style.color = ''; } + try { + await window.pork.payloadTcpSend({ localPath: local_path, port, filename }); + if (status) { status.textContent = `\u2713 ${filename} sent \u2014 PS5 notified`; status.style.color = 'var(--teal)'; } + setStatus(`${filename} sent to PS5 on port ${port}`, 'ok'); + } catch (e2) { + if (status) { status.textContent = `Error: ${e2.message}`; status.style.color = 'var(--red)'; } + setStatus(`TCP send failed: ${e2.message}`, 'error'); + } finally { + btn.disabled = false; + btn.textContent = '\u21D2 Send'; + } +}); + +$('autoloader-file-list').addEventListener('click', async e => { + const saveBtn = e.target.closest('.btn-al-save-local'); + if (saveBtn) { + e.stopPropagation(); // don't also add to sequence + const filename = saveBtn.dataset.filename; + const orig = saveBtn.textContent; + saveBtn.disabled = true; + saveBtn.textContent = '⋯'; + try { + await window.pork.autoloaderSaveLocal({ filename }); + setStatus(`${filename} saved to local payloads`, 'ok'); + saveBtn.textContent = '✓'; + await renderLocalPayloads(); + await renderPayloadSender(); + setTimeout(() => { saveBtn.textContent = orig; saveBtn.disabled = false; }, 2000); + } catch (e2) { + setStatus(`Save failed: ${e2.message}`, 'error'); + saveBtn.textContent = orig; + saveBtn.disabled = false; + } + return; + } + const card = e.target.closest('[data-al-add]'); + if (!card) return; + _alSequence.push({ type: 'payload', filename: card.dataset.alAdd }); + _alRenderSequence(); + $('btn-autoloader-save').disabled = false; +}); + +window.pork.on('payload:download:progress', ({ asset_name, percent }) => { + const btn = document.querySelector(`.btn-payload-download[data-asset-name="${CSS.escape(asset_name)}"]`); + if (btn) btn.textContent = `${percent}%`; +}); + diff --git a/renderer/modules/pfs-ripper.js b/renderer/modules/pfs-ripper.js new file mode 100644 index 0000000..f444285 --- /dev/null +++ b/renderer/modules/pfs-ripper.js @@ -0,0 +1,160 @@ +// ── PFS Ripper — PS5 PFS/image library browser ─────────────────────────────── +// Native port of Deckerr97's "PFS Ripper": browse a library of PS5 image files, +// show cover art (ProsperoPatches), search, view PPSA IDs + versions, export. + +let _pfsInit = false; +let _pfsItems = []; +let _pfsSearch = ''; +const _pfsIconCache = new Map(); + +function _pfsEsc(s) { return (typeof escHtml === 'function') ? escHtml(s) : String(s ?? ''); } +function _pfsToast(m, k) { if (typeof showToast === 'function') showToast(m, k); } +function _pfsFmt(n) { + if (!n) return ''; + if (n < 1048576) return (n / 1024).toFixed(0) + ' KB'; + if (n < 1073741824) return (n / 1048576).toFixed(1) + ' MB'; + return (n / 1073741824).toFixed(2) + ' GB'; +} + +function _pfsBuildUI() { + const root = document.getElementById('page-pfs-ripper'); + if (!root) return; + root.innerHTML = ` +
    +
    + PFS Ripper + + + +
    +
    + +
    +
    +
    +
    `; + + document.getElementById('pfs-add').addEventListener('click', async () => { + await window.pork.pfsFoldersAdd(); + await _pfsRenderFolders(); + await _pfsScan(); + }); + document.getElementById('pfs-rescan').addEventListener('click', () => _pfsScan()); + document.getElementById('pfs-search').addEventListener('input', e => { + _pfsSearch = e.target.value.toLowerCase(); + _pfsRenderGrid(); + }); +} + +async function _pfsRenderFolders() { + const el = document.getElementById('pfs-folders'); + if (!el) return; + const folders = (await window.pork.pfsFoldersList().catch(() => [])) || []; + if (!folders.length) { + el.innerHTML = 'No library folders yet — click “Add Folder”.'; + return; + } + el.innerHTML = folders.map(f => + ` + ${_pfsEsc(f)} + + `).join(''); + el.querySelectorAll('.pfs-folder-rm').forEach(b => + b.addEventListener('click', async () => { + await window.pork.pfsFoldersRemove(b.dataset.folder); + await _pfsRenderFolders(); + await _pfsScan(); + })); +} + +async function _pfsScan() { + const grid = document.getElementById('pfs-grid'); + if (grid) grid.innerHTML = '
    Scanning…
    '; + _pfsItems = (await window.pork.pfsScan().catch(() => [])) || []; + _pfsRenderGrid(); +} + +function _pfsFiltered() { + if (!_pfsSearch) return _pfsItems; + return _pfsItems.filter(it => + (it.gameId || '').toLowerCase().includes(_pfsSearch) || + (it.file || '').toLowerCase().includes(_pfsSearch)); +} + +function _pfsRenderGrid() { + const grid = document.getElementById('pfs-grid'); + const countEl = document.getElementById('pfs-count'); + if (!grid) return; + const list = _pfsFiltered(); + if (countEl) countEl.textContent = `${list.length} image${list.length !== 1 ? 's' : ''}`; + if (!list.length) { + grid.innerHTML = '
    No images found. Add a folder containing .ffpkg / .pkg / .exfat images.
    '; + return; + } + grid.innerHTML = list.map((it, i) => ` +
    +
    + ${it.gameId ? _pfsEsc(it.gameId) : '—'} +
    +
    +
    ${_pfsEsc(it.gameId || it.file)}
    +
    + ${_pfsEsc(it.ext)} + ${it.version ? ' v' + _pfsEsc(it.version) : ''} · ${_pfsFmt(it.size)} +
    +
    + + + +
    +
    +
    `).join(''); + + // Lazy-load cover art via ProsperoPatches. + grid.querySelectorAll('.pfs-art[data-gid]').forEach(el => { + const gid = el.dataset.gid; + if (!gid) return; + _pfsLoadArt(gid, el); + }); + + grid.querySelectorAll('.pfs-export').forEach(b => b.addEventListener('click', () => _pfsExport(list[+b.dataset.i]))); + grid.querySelectorAll('.pfs-ftp').forEach(b => b.addEventListener('click', () => _pfsFtp(list[+b.dataset.i]))); + grid.querySelectorAll('.pfs-reveal').forEach(b => b.addEventListener('click', () => window.pork.pfsReveal(list[+b.dataset.i].path))); +} + +async function _pfsLoadArt(gid, el) { + try { + let url = _pfsIconCache.get(gid); + if (url === undefined) { + const result = await window.pork.prosperoFetchIcon(gid).catch(() => null); + url = result?.iconUrl || ''; + _pfsIconCache.set(gid, url || null); + } + if (url && el.isConnected) { + el.innerHTML = ``; + } + } catch (_) { /* leave the ID placeholder */ } +} + +async function _pfsExport(it) { + if (!it) return; + try { + const r = await window.pork.pfsExportCopy(it.path); + if (r && r.ok) _pfsToast('Exported to ' + r.dest, 'ok'); + } catch (e) { _pfsToast('Export failed: ' + e.message, 'error'); } +} + +async function _pfsFtp(it) { + if (!it) return; + try { + await window.pork.pfsExportFtp(it.path); + _pfsToast('Queued upload to PS5 — see Transfers', 'ok'); + } catch (e) { _pfsToast('Send failed: ' + e.message, 'error'); } +} + +// Entry point invoked by the page router (state.js pageLoaders). +async function loadPfsRipper() { + if (!_pfsInit) { _pfsInit = true; _pfsBuildUI(); } + await _pfsRenderFolders(); + await _pfsScan(); +} diff --git a/renderer/modules/premium-illustrations.js b/renderer/modules/premium-illustrations.js new file mode 100644 index 0000000..23ee706 --- /dev/null +++ b/renderer/modules/premium-illustrations.js @@ -0,0 +1,203 @@ +'use strict'; + +(function mountPremiumProjectIllustrations() { + const targets = Object.freeze({ + 'games-empty': 'library', + 'backups-empty': 'protection', + 'transfers-empty': 'transfer', + 'media-empty': 'media', + }); + const illustrations = Object.freeze({ + 'library': ``, + 'protection': ``, + 'transfer': ``, + 'media': ``, + 'trophies': ``, + }); + const reducedMotion = typeof window.matchMedia === 'function' + ? window.matchMedia('(prefers-reduced-motion: reduce)') + : { matches: false, addListener() {}, removeListener() {} }; + const mounted = new Set(); + const visibilityObserver = 'IntersectionObserver' in window ? new IntersectionObserver(entries => { + for (const entry of entries) { + entry.target.classList.toggle('is-active', entry.isIntersecting); + syncMotion(entry.target); + } + }, { threshold: .16 }) : null; + + function motionPaused() { + return reducedMotion.matches || document.visibilityState !== 'visible'; + } + + function syncMotion(element) { + if (!element) return; + element.classList.toggle('is-paused', motionPaused()); + } + + function syncAllMotion() { + for (const element of mounted) { + if (!element.isConnected) { + visibilityObserver?.unobserve(element); + mounted.delete(element); + continue; + } + syncMotion(element); + } + } + + function bind(illustration) { + if (!illustration || mounted.has(illustration)) return; + syncAllMotion(); + mounted.add(illustration); + visibilityObserver?.observe(illustration); + if (!visibilityObserver) illustration.classList.add('is-active'); + syncMotion(illustration); + let pointerFrame = 0; + let pointerX = 0; + let pointerY = 0; + illustration.addEventListener('pointermove', event => { + if (motionPaused() || event.pointerType === 'touch') return; + pointerX = event.clientX; + pointerY = event.clientY; + if (pointerFrame) return; + pointerFrame = window.requestAnimationFrame(() => { + pointerFrame = 0; + const rect = illustration.getBoundingClientRect(); + illustration.style.setProperty('--illustration-x', `${((pointerX - rect.left) / rect.width - .5) * 7}deg`); + illustration.style.setProperty('--illustration-y', `${((pointerY - rect.top) / rect.height - .5) * -5}deg`); + }); + }, { passive: true }); + illustration.addEventListener('pointerleave', () => { + if (pointerFrame) window.cancelAnimationFrame(pointerFrame); + pointerFrame = 0; + illustration.style.removeProperty('--illustration-x'); + illustration.style.removeProperty('--illustration-y'); + }); + } + + function bindHomeTopology() { + const home = document.getElementById('chimera-home-command-center'); + if (!home) return; + home.classList.add('premium-topology-motion'); + bind(home); + } + + function dockImportLauncher() { + const launcher = document.getElementById('chimera-import-open'); + const sidebarDonate = document.getElementById('sidebar-donate'); + if (!launcher || !sidebarDonate) return false; + launcher.classList.add('chimera-import-launcher-docked'); + sidebarDonate.after(launcher); + return true; + } + + function ensureTrophyIllustration() { + const target = document.querySelector('.trophy-library-empty'); + if (!target) return; + let illustration = target.querySelector('.premium-project-illustration'); + if (!illustration) { + target.insertAdjacentHTML('afterbegin', illustrations.trophies); + illustration = target.querySelector('.premium-project-illustration'); + } + bind(illustration); + } + + for (const [id, variant] of Object.entries(targets)) { + const target = document.getElementById(id); + if (!target) continue; + const ensure = () => { + let illustration = target.querySelector('.premium-project-illustration'); + if (!illustration) { + target.insertAdjacentHTML('afterbegin', illustrations[variant]); + illustration = target.querySelector('.premium-project-illustration'); + } + bind(illustration); + }; + ensure(); + new MutationObserver(ensure).observe(target, { childList: true }); + } + + // Trophy content is rendered by a route module and the page node may be + // replaced while the feature is loading. Observe the stable content root so + // both a late render and a replacement page receive the illustration. + const trophyHost = document.getElementById('content') || document.body; + const touchesTrophyLibrary = mutation => { + const target = mutation.target?.nodeType === Node.ELEMENT_NODE ? mutation.target : mutation.target?.parentElement; + if (target?.matches?.('#page-trophies, #page-trophies *')) return true; + return [...mutation.addedNodes].some(node => node.nodeType === Node.ELEMENT_NODE && ( + node.matches?.('#page-trophies, .trophy-library-empty') + || node.querySelector?.('#page-trophies, .trophy-library-empty') + )); + }; + ensureTrophyIllustration(); + new MutationObserver(mutations => { + if (mutations.some(touchesTrophyLibrary)) ensureTrophyIllustration(); + }).observe(trophyHost, { childList: true, subtree: true }); + bindHomeTopology(); + if (!dockImportLauncher()) { + const importLauncherObserver = new MutationObserver(() => { + if (!dockImportLauncher()) return; + importLauncherObserver.disconnect(); + }); + importLauncherObserver.observe(document.body, { childList: true }); + } + document.addEventListener('visibilitychange', syncAllMotion); + if (typeof reducedMotion.addEventListener === 'function') reducedMotion.addEventListener('change', syncAllMotion); + else reducedMotion.addListener(syncAllMotion); +})(); diff --git a/renderer/modules/protection-model.js b/renderer/modules/protection-model.js new file mode 100644 index 0000000..1e45223 --- /dev/null +++ b/renderer/modules/protection-model.js @@ -0,0 +1,103 @@ +(function initProtectionModel(root, factory) { + const api = factory(); + if (typeof module === 'object' && module.exports) module.exports = api; + if (root) root.chimeraProtectionModel = api; +})(typeof window !== 'undefined' ? window : globalThis, function createProtectionModel() { + 'use strict'; + + const list = value => Array.isArray(value) ? value : []; + const value = input => input == null || input === '' ? null : input; + const first = (...inputs) => inputs.map(value).find(item => item != null) ?? null; + const lower = input => String(input || '').trim().toLowerCase(); + const missingStates = new Set(['missing', 'unavailable', 'disconnected', 'offline', 'not_mounted', 'absent']); + const failedIntegrityStates = new Set(['failed', 'corrupt', 'mismatch', 'invalid']); + + function normalizeCopy(title, copy, index) { + const volume = copy.volume || {}; + const device = copy.device || volume.device || copy.storageDevice || {}; + const id = first(copy.id, copy.copyId, copy.copy_id); + const volumeId = first(copy.volumeId, copy.volume_id, volume.id); + const deviceId = first(copy.deviceId, copy.device_id, device.id, volume.deviceId, volume.device_id); + const deviceFingerprint = first(copy.deviceFingerprint, copy.stableFingerprint, device.stableFingerprint, device.stable_fingerprint); + const volumeKey = first(copy.stableVolumeKey, copy.stable_volume_key, volume.stableVolumeKey, volume.stable_volume_key); + const deviceKey = deviceId != null ? `device:${deviceId}` : deviceFingerprint ? `fingerprint:${deviceFingerprint}` : 'device:unknown'; + const relativePath = first(copy.relativePath, copy.relative_path, copy.path, ''); + const mountPath = first(copy.mountPath, copy.mount_path, volume.mountPath, volume.mount_path, ''); + const fullPath = mountPath && relativePath + ? `${String(mountPath).replace(/[\\/]+$/, '')}/${String(relativePath).replace(/^[\\/]+/, '')}` + : String(first(relativePath, mountPath, 'Unknown path')); + const availabilityState = lower(first(copy.availabilityState, copy.availability_state, volume.availabilityState, volume.availability_state, device.availabilityState, device.availability_state, 'unknown')); + const integrityState = lower(first(copy.integrityState, copy.integrity_state, 'unknown')); + const hashState = lower(first(copy.hashState, copy.hash_state, copy.inventoryHash || copy.inventory_hash ? 'recorded' : 'missing')); + const missing = missingStates.has(availabilityState); + const integrityFailed = failedIntegrityStates.has(integrityState); + + return Object.freeze({ + copyId: id != null ? String(id) : `unidentified-${index + 1}`, + titleId: String(first(title.game_id, title.titleId, title.title_id, title.id, 'unknown')), + titleName: String(first(title.prospero_name, title.name, title.title, title.game_id, 'Unknown title')), + region: String(first(title.prospero_region, title.region, '')), + deviceId: deviceId == null ? null : String(deviceId), + deviceKey, + deviceName: String(first(copy.deviceName, copy.displayName, device.displayName, device.display_name, deviceFingerprint, 'Unknown device')), + deviceFingerprint: deviceFingerprint == null ? null : String(deviceFingerprint), + volumeId: volumeId == null ? null : String(volumeId), + volumeKey: volumeKey == null ? null : String(volumeKey), + path: fullPath, + sizeBytes: Number(first(copy.sizeBytes, copy.size_bytes, 0)) || 0, + hash: first(copy.inventoryHash, copy.inventory_hash), + hashAlgorithm: String(first(copy.hashAlgorithm, copy.hash_algorithm, 'sha256')), + hashState, + integrityState, + lastVerifiedAt: first(copy.lastVerifiedAt, copy.last_verified_at), + lastSeenAt: first(copy.lastSeenAt, copy.last_seen_at), + availabilityState, + missing, + integrityFailed, + restoreTarget: Object.freeze({ + copyId: id != null ? String(id) : `unidentified-${index + 1}`, + titleId: String(first(title.game_id, title.titleId, title.title_id, title.id, 'unknown')), + volumeId: volumeId == null ? null : String(volumeId), + deviceId: deviceId == null ? null : String(deviceId), + path: fullPath + }) + }); + } + + function evaluate(copies, requiredDistinctDevices = 2) { + const concrete = list(copies); + const healthyCopies = concrete.filter(copy => !copy.missing && !copy.integrityFailed); + const knownDevices = new Set(healthyCopies.map(copy => copy.deviceKey).filter(key => key !== 'device:unknown')); + const unknownDeviceCopies = healthyCopies.filter(copy => copy.deviceKey === 'device:unknown').length; + const distinctDevices = knownDevices.size + (unknownDeviceCopies ? 1 : 0); + const required = Math.max(1, Number(requiredDistinctDevices) || 2); + const missingCopies = concrete.filter(copy => copy.missing).length; + const failedCopies = concrete.filter(copy => copy.integrityFailed).length; + const healthy = distinctDevices >= required; + const sameDeviceExcess = Math.max(0, healthyCopies.length - distinctDevices); + const status = healthy ? 'healthy' : healthyCopies.length ? 'needs-diversity' : concrete.length ? 'degraded' : 'unprotected'; + return Object.freeze({ + copyCount: concrete.length, + healthyCopyCount: healthyCopies.length, + distinctDeviceCount: distinctDevices, + requiredDistinctDevices: required, + missingCopyCount: missingCopies, + failedCopyCount: failedCopies, + sameDeviceExcess, + healthy, + status + }); + } + + function flatten(titles, options = {}) { + const result = []; + for (const title of list(titles)) { + const copies = list(title.localCopies).map((copy, index) => normalizeCopy(title, copy, index)); + const policy = evaluate(copies, options.requiredDistinctDevices); + copies.forEach(copy => result.push(Object.freeze({ ...copy, policy }))); + } + return result; + } + + return Object.freeze({ normalizeCopy, evaluate, flatten }); +}); diff --git a/renderer/modules/psnotify.js b/renderer/modules/psnotify.js new file mode 100644 index 0000000..cd4f001 --- /dev/null +++ b/renderer/modules/psnotify.js @@ -0,0 +1,268 @@ +// ── PS Notify page ──────────────────────────────────────────────────────────── + +let _psnotifyConfigured = false; + +function applyPsNotifyAvailability(configured, detail = '') { + _psnotifyConfigured = configured; + ['btn-psn-send', 'btn-psn-test', 'btn-psn-push-payload'].forEach(id => { + const button = $(id); + if (!button) return; + button.disabled = !configured; + button.title = configured ? '' : 'Configure a PS5 host in Settings before using this action.'; + }); + document.querySelectorAll('#page-psnotify [id^="btn-psn-exp-"]').forEach(button => { + button.disabled = !configured; + button.title = configured ? '' : 'Configure a PS5 host in Settings before using this action.'; + }); + document.querySelectorAll('.psnotify-compose-card, .psnotify-payload-card').forEach(card => { + card.classList.toggle('is-unavailable', !configured); + }); + const status = $('psn-status'); + if (!configured && status && !status.textContent) { + status.textContent = detail || 'Configure a PS5 host in Settings to enable console actions.'; + } +} + +function enhancePsNotifyLayout() { + const page = $('page-psnotify'); + if (!page || page.dataset.enhanced) return; + page.dataset.enhanced = 'true'; + const header = page.querySelector(':scope > .page-header'); + header?.insertAdjacentHTML('beforeend', '

    Compose, test and review PS5 notifications from one focused workspace.

    Not checked'); + const cards = [...page.querySelectorAll(':scope > .card')]; + const [compose, settings, payload, lab, history] = cards; + if (!compose || !settings || !payload || !lab || !history) return; + compose.classList.add('psnotify-compose-card'); + settings.classList.add('psnotify-settings-card'); + payload.classList.add('psnotify-payload-card'); + lab.classList.add('psnotify-lab', 'collapsed'); + history.classList.add('psnotify-history-card'); + compose.querySelector('.card-header h3').textContent = 'Compose notification'; + compose.querySelector('.hint').textContent = 'Sent to the PS5 host from your active console profile.'; + compose.querySelector('.form-grid')?.insertAdjacentHTML('beforebegin', ` +
    + + + + +
    `); + compose.querySelector('.form-grid')?.insertAdjacentHTML('afterend', ` +
    + PS5 preview + Your notification appears here + Optional supporting detail +
    `); + $('psn-message')?.insertAdjacentHTML('afterend', '0/120'); + $('psn-submessage')?.insertAdjacentHTML('afterend', '0/120'); + settings.querySelector('.card-header h3').textContent = 'Service connection'; + payload.querySelector('.card-header h3').textContent = 'Notification payload'; + history.querySelector('.card-header h3').textContent = 'Notification history'; + lab.querySelector('.card-header')?.insertAdjacentHTML('beforeend', ''); + const grid = document.createElement('div'); + grid.className = 'psnotify-grid'; + compose.insertAdjacentElement('beforebegin', grid); + grid.append(compose); + const side = document.createElement('div'); + side.className = 'psnotify-side'; + side.append(settings, payload); + grid.append(side); + history.insertAdjacentElement('afterend', lab); + $('psn-lab-toggle')?.addEventListener('click', event => { + const expanded = event.currentTarget.getAttribute('aria-expanded') === 'true'; + event.currentTarget.setAttribute('aria-expanded', String(!expanded)); + event.currentTarget.textContent = expanded ? 'Show advanced lab' : 'Hide advanced lab'; + lab.classList.toggle('collapsed', expanded); + }); + const templates = { + complete: ['Build complete', 'The latest Chimera task finished successfully.'], + backup: ['Backup complete', 'Your PS5 data has been verified and stored.'], + update: ['Update ready', 'A reviewed update is ready in Chimera.'], + warning: ['Attention needed', 'Open Chimera for details and the recommended next step.'], + }; + compose.querySelector('.psnotify-templates')?.addEventListener('click', event => { + const button = event.target.closest('[data-psn-template]'); + if (!button) return; + [$('psn-message').value, $('psn-submessage').value] = templates[button.dataset.psnTemplate]; + updatePsNotifyPreview(); + $('psn-message').focus(); + }); +} + +function updatePsNotifyPreview() { + const message = $('psn-message')?.value || ''; + const submessage = $('psn-submessage')?.value || ''; + if ($('psn-message-count')) $('psn-message-count').textContent = `${message.length}/120`; + if ($('psn-submessage-count')) $('psn-submessage-count').textContent = `${submessage.length}/120`; + if ($('psn-preview-message')) $('psn-preview-message').textContent = message || 'Your notification appears here'; + if ($('psn-preview-submessage')) $('psn-preview-submessage').textContent = submessage || 'Optional supporting detail'; +} + +async function loadPsNotify() { + enhancePsNotifyLayout(); + // Populate settings fields from store + try { + const s = await window.pork.getSettings(); + $('psn-enabled').checked = s.psnotifyEnabled !== false; + $('psn-port').value = s.psnotifyPort || 6969; + const readiness = $('psn-page-readiness'); + if (readiness) { + readiness.textContent = s.ftpHost ? `Configured · ${s.ftpHost}:${s.psnotifyPort || 6969}` : 'PS5 host not configured'; + readiness.className = `operations-status ${s.ftpHost ? 'operations-status-compatible' : 'operations-status-warning'}`; + } + applyPsNotifyAvailability(Boolean(s.ftpHost)); + } catch (_) { + applyPsNotifyAvailability(false, 'Console configuration could not be loaded.'); + } + updatePsNotifyPreview(); + await renderPsnHistory(); +} + +$('psn-message').addEventListener('input', updatePsNotifyPreview); +$('psn-submessage').addEventListener('input', updatePsNotifyPreview); +$('psn-message').addEventListener('keydown', event => { + if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') $('btn-psn-send').click(); +}); + +async function renderPsnHistory() { + const hist = await window.pork.psnotifyHistory(); + const list = $('psn-history'); + const empty = $('psn-history-empty'); + if (!hist.length) { + list.innerHTML = ''; + empty.hidden = false; + return; + } + empty.hidden = true; + list.innerHTML = hist.slice().reverse().map(e => { + const cls = e.ok !== false ? 'psn-ok' : 'psn-fail'; + const ts = new Date(e.ts).toLocaleTimeString(); + const sub = e.subMessage ? `\u2014 ${escHtml(e.subMessage)}` : ''; + return `
    + ${ts} + ${escHtml(e.message)}${sub} +
    `; + }).join(''); +} + +$('btn-psn-send').addEventListener('click', async () => { + if (!_psnotifyConfigured) return; + const msg = $('psn-message').value.trim(); + const sub = $('psn-submessage').value.trim(); + if (!msg) { $('psn-status').textContent = 'Message is required.'; return; } + const btn = $('btn-psn-send'); + btn.disabled = true; + $('psn-status').textContent = 'Sending\u2026'; + try { + await window.pork.psnotifySend(msg, sub); + $('psn-status').textContent = 'Sent \u2713'; + $('psn-message').value = ''; + $('psn-submessage').value = ''; + updatePsNotifyPreview(); + await renderPsnHistory(); + } catch (e) { + $('psn-status').textContent = 'Error: ' + e.message; + } + btn.disabled = !_psnotifyConfigured; +}); + +$('btn-psn-test').addEventListener('click', async () => { + if (!_psnotifyConfigured) return; + const btn = $('btn-psn-test'); + btn.disabled = true; + $('psn-status').textContent = 'Sending test\u2026'; + try { + await window.pork.psnotifyTest(); + $('psn-status').textContent = 'Test sent \u2713'; + if ($('psn-page-readiness')) { + $('psn-page-readiness').textContent = 'Online'; + $('psn-page-readiness').className = 'operations-status operations-status-compatible'; + } + await renderPsnHistory(); + } catch (e) { + $('psn-status').textContent = 'Error: ' + e.message; + if ($('psn-page-readiness')) { + $('psn-page-readiness').textContent = 'Offline'; + $('psn-page-readiness').className = 'operations-status operations-status-warning'; + } + } + btn.disabled = !_psnotifyConfigured; +}); + +$('btn-psn-save-settings').addEventListener('click', async () => { + const port = parseInt($('psn-port').value) || 6969; + await window.pork.setSettings({ + psnotifyEnabled: $('psn-enabled').checked, + psnotifyPort: port, + }); + $('psn-port').value = port; + $('psn-status').textContent = 'Settings saved \u2713'; +}); + +$('btn-psn-refresh-history').addEventListener('click', renderPsnHistory); + +// ── PS Notify colour/encoding experiments ──────────────────────────────────────── + +async function _sendExp(msg, sub = '') { + const el = $('psn-exp-status'); + if (!_psnotifyConfigured) { + el.textContent = 'Configure a PS5 host in Settings first.'; + return; + } + el.textContent = 'Sending…'; + try { + await window.pork.psnotifySend(msg, sub); + el.textContent = 'Sent \u2713 — check your PS5'; + await renderPsnHistory(); + } catch (e) { + el.textContent = 'Error: ' + e.message; + } +} + +$('btn-psn-push-payload').addEventListener('click', async () => { + if (!_psnotifyConfigured) return; + const btn = $('btn-psn-push-payload'); + const st = $('psn-push-status'); + btn.disabled = true; + st.textContent = 'Queuing upload\u2026'; + try { + const res = await window.pork.psnotifyPushPayload(); + st.textContent = `Queued \u2713 \u2014 sending to ${res.remotePath}. Reload the payload on your PS5 then emoji will work.`; + } catch (e) { + st.textContent = 'Error: ' + e.message; + } + btn.disabled = !_psnotifyConfigured; +}); + +// HTML markup +$('btn-psn-exp-html-red').addEventListener('click', () => + _sendExp('This is red text', 'HTML test')); + +$('btn-psn-exp-html-multi').addEventListener('click', () => + _sendExp('Red Green Blue', 'HTML multi-colour test')); + +$('btn-psn-exp-sony-tag').addEventListener('click', () => + _sendExp('$[color=ff0000]Red$[/color] $[color=00ff00]Green$[/color]', 'Sony $[color] tag test')); + +// Emoji colour squares (4-byte UTF-8 — need TextDecoder patch in customPSNotify.js to arrive intact) +$('btn-psn-exp-sq-red').addEventListener('click', () => + _sendExp('\uD83D\uDFE5 Red notification', 'Emoji \uD83D\uDFE5 (U+1F7E5, 4-byte UTF-8)')); + +$('btn-psn-exp-sq-green').addEventListener('click', () => + _sendExp('\uD83D\uDFE9 Green notification', 'Emoji \uD83D\uDFE9 (U+1F7E9, 4-byte UTF-8)')); + +$('btn-psn-exp-sq-blue').addEventListener('click', () => + _sendExp('\uD83D\uDFE6 Blue notification', 'Emoji \uD83D\uDFE6 (U+1F7E6, 4-byte UTF-8)')); + +$('btn-psn-exp-sq-yellow').addEventListener('click', () => + _sendExp('\uD83D\uDFE8 Yellow notification', 'Emoji \uD83D\uDFE8 (U+1F7E8, 4-byte UTF-8)')); + +$('btn-psn-exp-sq-all').addEventListener('click', () => + _sendExp('\uD83D\uDFE5\uD83D\uDFE7\uD83D\uDFE8\uD83D\uDFE9\uD83D\uDFE6\uD83D\uDFEA Colour squares test', 'All 6 colour squares')); + +// ANSI escape codes +$('btn-psn-exp-ansi-red').addEventListener('click', () => + _sendExp('\x1b[31mRed ANSI text\x1b[0m', 'ANSI \\x1b[31m colour test')); + +$('btn-psn-exp-ansi-bold').addEventListener('click', () => + _sendExp('\x1b[1mBold ANSI text\x1b[0m', 'ANSI \\x1b[1m bold test')); + diff --git a/renderer/modules/pwa.js b/renderer/modules/pwa.js new file mode 100644 index 0000000..50ed076 --- /dev/null +++ b/renderer/modules/pwa.js @@ -0,0 +1,5 @@ +'use strict'; + +if ('serviceWorker' in navigator && window.isSecureContext && window.pork?.isWebMode) { + window.addEventListener('load', () => navigator.serviceWorker.register('/service-worker.js', { scope: '/' }).catch(error => console.warn('[Chimera PWA]', error.message))); +} diff --git a/renderer/modules/savemgr.js b/renderer/modules/savemgr.js new file mode 100644 index 0000000..c47c347 --- /dev/null +++ b/renderer/modules/savemgr.js @@ -0,0 +1,561 @@ +// ── Local Save Manager (native GarlicMgr UI) ──────────────────────────────── + +// ── State ─────────────────────────────────────────────────────────────────── +const smState = { + saves: [], selectedIdx: -1, mounted: false, + files: [], expanded: new Set(), + busy: false, tab: 'browse', +}; +let _smHasSuccessfulWarm = false; + +// ── Terminal log ───────────────────────────────────────────────────────────── +function smLog(msg, cls) { + const logEl = $('sm-log'); + if (!logEl) return; + const t = new Date().toLocaleTimeString(); + const div = document.createElement('div'); + div.className = 'sm-ll' + (cls ? ' sm-' + cls : ''); + div.innerHTML = `${t}${escHtml(msg)}`; + logEl.appendChild(div); + logEl.scrollTop = logEl.scrollHeight; +} + +// ── Confirmation modal (native browser dialogs are blocked) ───────────────── +function smConfirm(message) { + return new Promise(resolve => { + const bg = document.createElement('div'); + bg.style.cssText = 'position:fixed;inset:0;z-index:9900;background:rgba(0,0,0,.65);display:flex;align-items:center;justify-content:center;'; + bg.setAttribute('aria-hidden', 'true'); + bg.innerHTML = `
    +

    ${escHtml(message)}

    +
    + + +
    `; + document.body.appendChild(bg); + const cleanup = result => { + closeAccessibleDialog(bg); + bg.remove(); + resolve(result); + }; + bg.querySelector('#smconf-skip').addEventListener('click', () => cleanup(false)); + bg.querySelector('#smconf-ok').addEventListener('click', () => cleanup(true)); + bg.addEventListener('keydown', event => { if (event.key === 'Escape') cleanup(false); }); + openAccessibleDialog(bg, bg.querySelector('#smconf-skip')); + }); +} + +// ── Helpers ────────────────────────────────────────────────────────────────── +function smFmtBytes(b) { + if (!b) return '0B'; + if (b < 1024) return b + 'B'; + if (b < 1048576) return (b / 1024).toFixed(1) + 'K'; + return (b / 1048576).toFixed(1) + 'M'; +} +function smSetBusy(v) { + smState.busy = v; + ['btn-sm-dl-zip','btn-sm-dl-enc','btn-sm-dump-usb','btn-sm-upload','btn-sm-unmount'] + .forEach(id => { const el = $(id); if (el) el.disabled = v; }); +} +function smShowUsbProgress(pct) { + const bar = $('sm-usb-progress'); if (bar) bar.hidden = false; + const fill = $('sm-usb-fill'); if (fill) fill.style.width = pct + '%'; +} +function smHideUsbProgress() { const bar = $('sm-usb-progress'); if (bar) bar.hidden = true; } +function smShowUploadProgress(pct, loaded, total) { + const bar = $('sm-upload-progress'); if (bar) bar.hidden = false; + const fill = $('sm-upload-fill'); if (fill) fill.style.width = pct + '%'; + const txt = $('sm-upload-text'); if (txt) txt.textContent = `${smFmtBytes(loaded)} / ${smFmtBytes(total)} (${pct}%)`; +} +function smHideUploadProgress() { const bar = $('sm-upload-progress'); if (bar) bar.hidden = true; } + +// ── Load / reload saves ────────────────────────────────────────────────────── +async function loadSaveMgr(options = {}) { + const background = !!options.background; + const s = await window.pork.getSettings(); + const ip = s.ftpHost || ''; + const port = s.savemgrPort || 8082; + $('sm-ip-display').textContent = ip || '(not set — configure FTP Settings)'; + $('sm-port').value = port; + const ssp = $('s-savemgr-port'); if (ssp) ssp.value = port; + if (ip && (!background || _smHasSuccessfulWarm)) await smFetchSaves(); +} + +async function smFetchSaves() { + try { + const d = await window.pork.savemgrRequest('/api/saves'); + smState.saves = d.saves || []; + _smHasSuccessfulWarm = true; + smRenderSaves(); + smLog(`Loaded ${smState.saves.length} saves`, 'ok'); + } catch (e) { + smLog('Failed to load saves: ' + e.message, 'err'); + smLog('Start the session only when needed with “Start session”.'); + smState.saves = []; + smRenderSaves(); + } +} + +function smRenderSaves() { + const el = $('sm-saves-list'); + if (!smState.saves.length) { + el.innerHTML = '
    No saves found
    '; + return; + } + el.innerHTML = smState.saves.map((s, i) => + `
    +
    ${escHtml(s.title_id)}
    +
    ${escHtml(s.save_name)}
    +
    ` + ).join(''); + el.querySelectorAll('.sm-save-item').forEach(item => + item.addEventListener('click', () => smSelectSave(parseInt(item.dataset.idx))) + ); +} + +async function smSelectSave(idx) { + if (smState.busy || smState.tab !== 'browse') return; + smState.selectedIdx = idx; + smRenderSaves(); + await smMount(idx); +} + +async function smMount(idx) { + if (smState.busy) return; + smSetBusy(true); + const s = smState.saves[idx]; + smLog(`Mounting ${s.title_id}/${s.save_name}...`); + try { + const d = await window.pork.savemgrRequest(`/api/mount?idx=${idx}`); + if (d.error) { smLog('Mount failed: ' + d.error, 'err'); smSetBusy(false); return; } + smState.mounted = true; + smState.files = d.files || []; + smState.expanded = new Set(smState.files.filter(f => f.dir).map(f => f.name)); + $('sm-save-title-text').textContent = d.save_title || 'Untitled'; + $('sm-save-title-id').innerHTML = d.title_id ? `Title ID: ${escHtml(d.title_id)}` : ''; + $('sm-save-account-id').innerHTML = d.account_id ? `  Account: ${escHtml(d.account_id)}` : ''; + $('sm-save-ftp-row').textContent = d.mount ? `FTP: ${d.mount}` : ''; + $('sm-save-header').hidden = false; + $('sm-empty-state').hidden = true; + smLoadIcon(); + smRenderFileTree(); + smLog(`Mounted ${[d.title_id, d.save_title, d.account_id ? '['+d.account_id+']' : ''].filter(Boolean).join(' ')}`, 'ok'); + } catch (e) { smLog('Error: ' + e.message, 'err'); } + smSetBusy(false); +} + +async function smLoadIcon() { + try { + const dataUrl = await window.pork.savemgrIcon(); + const img = $('sm-game-icon'); + if (dataUrl) { img.src = dataUrl; img.hidden = false; } + else img.hidden = true; + } catch (_) { $('sm-game-icon').hidden = true; } +} + +// ── File tree ──────────────────────────────────────────────────────────────── +function smRenderFileTree() { + const tree = $('sm-filetree'); + tree.hidden = false; + let h = ''; + smState.files.forEach((f, i) => { + const parts = f.name.split('/'); parts.pop(); + let visible = true, pp = ''; + for (let j = 0; j < parts.length; j++) { + pp = j ? pp + '/' + parts[j] : parts[j]; + if (!smState.expanded.has(pp)) { visible = false; break; } + } + if (!visible) return; + const depth = f.name.split('/').length - 1; + const name = f.name.split('/').pop(); + const pad = 12 + depth * 16; + if (f.dir) { + const open = smState.expanded.has(f.name); + h += `
    + ${open ? '▾' : '▸'} 📁 ${escHtml(name)}
    `; + } else { + const fname = escHtml(f.name).replace(/'/g, "\\'"); + h += `
    + 📄 ${escHtml(name)}${smFmtBytes(f.size)}
    `; + } + }); + h += '
    Tip: drag & drop files here to add or replace them
    '; + tree.innerHTML = h; + tree.querySelectorAll('.sm-fi-dir').forEach(el => + el.addEventListener('click', () => { + const name = smState.files[parseInt(el.dataset.fidx)].name; + if (smState.expanded.has(name)) smState.expanded.delete(name); + else smState.expanded.add(name); + smRenderFileTree(); + }) + ); + tree.querySelectorAll('.sm-fi:not(.sm-fi-dir)').forEach(el => + el.addEventListener('contextmenu', e => { e.preventDefault(); smShowCtxMenu(e, el.dataset.fname); }) + ); + smSetupDropZone(tree, smHandleFileDrop); +} + +// ── Context menu ───────────────────────────────────────────────────────────── +document.addEventListener('click', () => { const m = $('sm-ctx-menu'); if (m) m.remove(); }); + +function smShowCtxMenu(e, fname) { + const old = $('sm-ctx-menu'); if (old) old.remove(); + const m = document.createElement('div'); + m.className = 'sm-ctx'; m.id = 'sm-ctx-menu'; + m.innerHTML = `
    Download
    +
    Upload File
    +
    Delete
    `; + m.style.left = e.clientX + 'px'; m.style.top = e.clientY + 'px'; + document.body.appendChild(m); + const r = m.getBoundingClientRect(); + if (r.right > window.innerWidth) m.style.left = (window.innerWidth - r.width - 4) + 'px'; + if (r.bottom > window.innerHeight) m.style.top = (window.innerHeight - r.height - 4) + 'px'; + m.querySelectorAll('[data-action]').forEach(item => item.addEventListener('click', () => { + m.remove(); + if (item.dataset.action === 'download') smDownloadFile(fname); + else if (item.dataset.action === 'upload') smPickUploadFile(); + else if (item.dataset.action === 'delete') smDeleteFile(fname); + })); +} + +// ── File operations ────────────────────────────────────────────────────────── +async function smDownloadFile(fname) { + smLog(`Downloading ${fname}...`); + try { + await window.pork.savemgrDownload(`/api/download_file?name=${encodeURIComponent(fname)}`, fname.split('/').pop()); + smLog(`Downloaded ${fname.split('/').pop()}`, 'ok'); + } catch (e) { smLog('Error: ' + e.message, 'err'); } +} + +function smPickUploadFile() { + const input = document.createElement('input'); + input.type = 'file'; input.multiple = true; + input.onchange = () => { if (input.files.length) smHandleFileDrop(Array.from(input.files)); }; + input.click(); +} + +async function smDeleteFile(fname) { + const ok = await smConfirm(`Delete ${fname}?`); + if (!ok) return; + try { + const d = await window.pork.savemgrRequest(`/api/delete_file?name=${encodeURIComponent(fname)}`); + if (d.error) throw new Error(d.error); + smLog(`Deleted ${fname}`, 'ok'); + await smRefreshFiles(); + } catch (e) { smLog('Error: ' + e.message, 'err'); } +} + +async function smRefreshFiles() { + const d = await window.pork.savemgrRequest('/api/files'); + smState.files = d.files || []; + smState.expanded = new Set(smState.files.filter(f => f.dir).map(f => f.name)); + smRenderFileTree(); +} + +async function smHandleFileDrop(files) { + if (smState.busy || !smState.mounted) return; + smSetBusy(true); + try { + for (let i = 0; i < files.length; i++) { + const file = files[i]; + if (file.name.endsWith('.zip')) { + smLog(`Extracting ${file.name} (${smFmtBytes(file.size)})...`); + const buf = await file.arrayBuffer(); + const d = await window.pork.savemgrUpload('/api/upload', buf); + smLog(`Extracted ${d.files} files`, 'ok'); + } else { + const ce = await window.pork.savemgrRequest(`/api/file_exists?name=${encodeURIComponent(file.name)}`); + if (ce.exists) { + const replace = await smConfirm(`${file.name} already exists. Replace it?`); + if (!replace) { smLog(`Skipped ${file.name}`); continue; } + } + smLog(`Uploading ${file.name} (${smFmtBytes(file.size)})...`); + const buf = await file.arrayBuffer(); + const d = await window.pork.savemgrUpload(`/api/upload_file?name=${encodeURIComponent(file.name)}`, buf); + smHideUploadProgress(); + if (d.ok) smLog(`Added ${file.name}`, 'ok'); + else smLog(d.error || 'Upload failed', 'err'); + } + } + await smRefreshFiles(); + } catch (e) { smLog('Error: ' + e.message, 'err'); } + smSetBusy(false); + smHideUploadProgress(); +} + +// ── Browse actions ──────────────────────────────────────────────────────────── +async function smDownloadZip() { + if (smState.busy) return; smSetBusy(true); + const s = smState.saves[smState.selectedIdx]; + const filename = s ? `${s.title_id}_${s.save_name}.zip` : 'save.zip'; + smLog('Downloading decrypted zip...'); + try { + await window.pork.savemgrDownload('/api/download', filename); + smLog(`Downloaded ${filename}`, 'ok'); + } catch (e) { smLog('Error: ' + e.message, 'err'); } + smSetBusy(false); +} + +async function smDownloadEncrypted() { + if (smState.busy || smState.selectedIdx < 0) return; smSetBusy(true); + const s = smState.saves[smState.selectedIdx]; + smLog('Unmounting and downloading encrypted save...'); + try { + await window.pork.savemgrRequest('/api/unmount'); + smState.mounted = false; + await window.pork.savemgrDownload(`/api/download_raw?idx=${smState.selectedIdx}`, `${s.title_id}_${s.save_name}`); + smState.selectedIdx = -1; smState.files = []; smState.expanded = new Set(); + smRenderSaves(); + $('sm-save-header').hidden = true; $('sm-filetree').hidden = true; $('sm-empty-state').hidden = false; + smLog('Downloaded encrypted save', 'ok'); + } catch (e) { smLog('Error: ' + e.message, 'err'); } + smSetBusy(false); +} + +async function smDumpToUsb() { + if (smState.busy || smState.selectedIdx < 0) return; smSetBusy(true); + smLog('Dumping to USB...'); + window.pork.on('savemgr:usb-progress', data => { smShowUsbProgress(data.progress); }); + try { + const result = await window.pork.savemgrDumpUsb(smState.selectedIdx); + window.pork.off('savemgr:usb-progress'); + smHideUsbProgress(); + smLog(result.path ? `Dumped to ${result.path} (${smFmtBytes(result.size)})` : 'Dump complete', 'ok'); + } catch (e) { window.pork.off('savemgr:usb-progress'); smHideUsbProgress(); smLog('Error: ' + e.message, 'err'); } + smSetBusy(false); +} + +async function smUnmount() { + if (smState.busy) return; smSetBusy(true); + try { + await window.pork.savemgrRequest('/api/unmount'); + smState.mounted = false; smState.selectedIdx = -1; smState.files = []; smState.expanded = new Set(); + smRenderSaves(); + $('sm-save-header').hidden = true; $('sm-filetree').hidden = true; $('sm-empty-state').hidden = false; + smLog('Unmounted', 'ok'); + } catch (e) { smLog('Error: ' + e.message, 'err'); } + smSetBusy(false); +} + +// ── Sub-tab switching ───────────────────────────────────────────────────────── +function smSwitchTab(tab) { + smState.tab = tab; + document.querySelectorAll('.sm-tab').forEach(btn => + btn.classList.toggle('active', btn.dataset.smTab === tab) + ); + document.querySelectorAll('.sm-pane').forEach(pane => + pane.hidden = (pane.id !== `sm-pane-${tab}`) + ); + if (tab === 'browse') smFetchSaves(); +} + +// ── Decrypt drop ────────────────────────────────────────────────────────────── +async function smHandleDecryptDrop(files) { + const file = files[0]; if (!file || smState.busy) return; + smSetBusy(true); + smLog(`Uploading ${file.name} (${smFmtBytes(file.size)}) for decrypt...`); + try { + const buf = await file.arrayBuffer(); + const filename = file.name.replace(/\.[^.]+$/, '') + '.zip'; + const r = await window.pork.savemgrDecrypt(buf, filename); + if (r.canceled) smLog('Cancelled'); + else smLog(`Decrypted → ${filename} (${smFmtBytes(r.size)})`, 'ok'); + } catch (e) { smLog('Error: ' + e.message, 'err'); } + smSetBusy(false); +} + +// ── Encrypt drop ────────────────────────────────────────────────────────────── +async function smHandleEncryptDrop(files, folderName) { + if (!files.length || smState.busy) return; + const aid = $('sm-enc-aid').value.trim(); + smSetBusy(true); + let totalSize = 0; for (const f of files) totalSize += f.size; + smLog(`Creating PFS image (${smFmtBytes(totalSize)} of files)...`); + try { + const pfs = await window.pork.savemgrCreatePfs(totalSize); + if (pfs.error) throw new Error(pfs.error); + smLog('PFS image created', 'ok'); + for (let i = 0; i < files.length; i++) { + const f = files[i]; + const raw = f._path || f.webkitRelativePath || f.name; + const parts = raw.split('/'); + const relPath = parts.length > 1 ? parts.slice(1).join('/') : parts[0]; + smLog(`Uploading (${i+1}/${files.length}) ${relPath}...`); + const buf = await f.arrayBuffer(); + const d = await window.pork.savemgrUpload(`/api/upload_file?name=${encodeURIComponent(relPath)}`, buf); + if (!d.ok) throw new Error(d.error || `Upload failed: ${relPath}`); + } + smLog(`All ${files.length} files uploaded`, 'ok'); + const name = folderName || 'encrypted_save'; + smLog('Downloading encrypted save...'); + const r = await window.pork.savemgrDownloadNew(name, aid); + if (r.canceled) smLog('Cancelled'); + else smLog(`Encrypted save downloaded (${smFmtBytes(r.size)})`, 'ok'); + } catch (e) { smLog('Error: ' + e.message, 'err'); } + smSetBusy(false); +} + +// ── Resign drop ─────────────────────────────────────────────────────────────── +async function smHandleResignDrop(files) { + const file = files[0]; if (!file || smState.busy) return; + const aid = $('sm-resign-aid').value.trim(); + if (!aid) { smLog('Enter new Account ID first', 'err'); return; } + smSetBusy(true); + smLog(`Uploading ${file.name} for resign...`); + try { + const buf = await file.arrayBuffer(); + const filename = file.name.replace(/\.[^.]+$/, '') + '_resigned'; + const r = await window.pork.savemgrResign(buf, aid, filename); + if (r.canceled) smLog('Cancelled'); + else smLog(`Resigned → ${filename} (${smFmtBytes(r.size)})`, 'ok'); + } catch (e) { smLog('Error: ' + e.message, 'err'); } + smSetBusy(false); +} + +// ── Drop zone setup ─────────────────────────────────────────────────────────── +function smSetupDropZone(el, handler, folderMode) { + el.ondragover = e => { e.preventDefault(); el.classList.add('sm-drag-over'); }; + el.ondragleave = e => { e.preventDefault(); el.classList.remove('sm-drag-over'); }; + el.ondrop = async e => { + e.preventDefault(); el.classList.remove('sm-drag-over'); + if (folderMode) { + const items = e.dataTransfer.items, entries = []; + for (let i = 0; i < items.length; i++) { + const ent = items[i].webkitGetAsEntry?.(); if (ent) entries.push(ent); + } + if (entries.length && entries[0].isDirectory) { + const files = await smReadFolderEntries(entries[0], ''); + handler(files, entries[0].name); + } else if (e.dataTransfer.files.length) handler(Array.from(e.dataTransfer.files)); + } else { + if (e.dataTransfer.files.length) handler(Array.from(e.dataTransfer.files)); + } + }; + const btn = el.querySelector('.sm-browse-btn'); + if (btn) btn.onclick = e => { + e.stopPropagation(); + const input = document.createElement('input'); input.type = 'file'; + if (folderMode) input.webkitdirectory = true; + input.onchange = () => { + if (!input.files.length) return; + if (folderMode) { const prefix = input.files[0].webkitRelativePath.split('/')[0]; handler(Array.from(input.files), prefix); } + else handler(Array.from(input.files)); + }; + input.click(); + }; +} + +async function smReadFolderEntries(entry, basePath) { + return new Promise(resolve => { + if (entry.isFile) { entry.file(f => { f._path = basePath + f.name; resolve([f]); }); } + else if (entry.isDirectory) { + const reader = entry.createReader(), all = []; + const readBatch = () => reader.readEntries(entries => { + if (!entries.length) { Promise.all(all).then(arrs => resolve(arrs.flat())); return; } + for (const e of entries) all.push(smReadFolderEntries(e, basePath + entry.name + '/')); + readBatch(); + }); + readBatch(); + } else resolve([]); + }); +} + +// ── Event wiring (run once on startup) ─────────────────────────────────────── +(function smInitEvents() { + document.querySelectorAll('.sm-tab').forEach(btn => + btn.addEventListener('click', () => smSwitchTab(btn.dataset.smTab)) + ); + + $('btn-sm-start-session').addEventListener('click', async event => { + const button = event.currentTarget; + const s = await window.pork.getSettings(); + const ip = s.ftpHost || ''; + const port = parseInt($('sm-port').value) || 8082; + if (!ip) { showToast('PS5 IP not set. Configure FTP Settings first.', 'error'); return; } + button.disabled = true; + await window.pork.setSettings({ savemgrPort: port }); + smLog('Starting Garlic Save Manager explicitly through PS5 Payload Manager…'); + try { + await window.pork.savemgrStart(); + smLog('Garlic Save Manager started', 'ok'); + showToast('Save Manager session started.', 'ok'); + await smFetchSaves(); + } catch (error) { + smLog('Start failed: ' + error.message, 'err'); + showToast(`Save Manager failed to start: ${error.message}`, 'error'); + } finally { + button.disabled = false; + } + }); + + $('btn-sm-load').addEventListener('click', async () => { + const s = await window.pork.getSettings(); + const ip = s.ftpHost || ''; + const port = parseInt($('sm-port').value) || 8082; + if (!ip) { showToast('PS5 IP not set. Configure FTP Settings first.', 'error'); return; } + await window.pork.setSettings({ savemgrPort: port }); + smFetchSaves(); + }); + + $('btn-sm-save').addEventListener('click', async () => { + const port = parseInt($('sm-port').value) || 8082; + await window.pork.setSettings({ savemgrPort: port }); + const ssp = $('s-savemgr-port'); if (ssp) ssp.value = port; + showToast('Save Manager port saved.', 'ok'); + }); + + $('btn-sm-open-ext').addEventListener('click', async () => { + const s = await window.pork.getSettings(); + const ip = s.ftpHost || ''; const port = parseInt($('sm-port').value) || 8082; + if (!ip) { showToast('PS5 IP not set.', 'error'); return; } + window.pork.openShell(`http://${ip}:${port}`); + }); + + $('btn-sm-dl-zip').addEventListener('click', smDownloadZip); + $('btn-sm-dl-enc').addEventListener('click', smDownloadEncrypted); + $('btn-sm-dump-usb').addEventListener('click', smDumpToUsb); + $('btn-sm-unmount').addEventListener('click', smUnmount); + $('btn-sm-upload').addEventListener('click', smPickUploadFile); + + smSetupDropZone($('sm-decrypt-drop'), smHandleDecryptDrop, false); + smSetupDropZone($('sm-encrypt-drop'), (f, n) => smHandleEncryptDrop(f, n), true); + smSetupDropZone($('sm-resign-drop'), smHandleResignDrop, false); + + // Drag-drop onto the right panel (file browser) + const rp = $('sm-right-pane'); + rp.ondragover = e => { e.preventDefault(); if (smState.mounted) rp.classList.add('sm-drag-over'); }; + rp.ondragleave = e => { e.preventDefault(); rp.classList.remove('sm-drag-over'); }; + rp.ondrop = e => { + e.preventDefault(); rp.classList.remove('sm-drag-over'); + if (smState.mounted && e.dataTransfer.files.length) smHandleFileDrop(Array.from(e.dataTransfer.files)); + }; + + $('btn-sm-push-payload').addEventListener('click', async () => { + const confirmation = 'INSTALLEER GARLIC AUTOLOAD'; + const entered = await showPrompt( + `Deze actie schrijft garlic-savemgr.elf en autoload.txt naar je PS5. Typ exact ${confirmation} om door te gaan:`, + '' + ); + if (entered !== confirmation) { + showToast('Installation cancelled: the confirmation phrase did not match exactly.', 'info'); + return; + } + const btn = $('btn-sm-push-payload'), st = $('sm-push-status'); + btn.disabled = true; st.textContent = 'Installing\u2026'; + try { + const res = await window.pork.savemgrPushPayload({ confirmation }); + st.textContent = res.appended + ? 'Installed \u2713 \u2014 garlic-savemgr.elf uploaded and added to autoload.txt. Restart your PS5 to apply.' + : 'Installed \u2713 \u2014 garlic-savemgr.elf uploaded (already in autoload.txt). Restart your PS5 to apply.'; + } catch (e) { st.textContent = 'Error: ' + e.message; } + btn.disabled = false; + }); + + const ssBtn = $('btn-s-savemgr-save'); + if (ssBtn) ssBtn.addEventListener('click', async () => { + const port = parseInt($('s-savemgr-port').value) || 8082; + await window.pork.setSettings({ savemgrPort: port }); + showToast('Save Manager port saved.', 'ok'); + }); +})(); + diff --git a/renderer/modules/settings.js b/renderer/modules/settings.js new file mode 100644 index 0000000..7250318 --- /dev/null +++ b/renderer/modules/settings.js @@ -0,0 +1,861 @@ + +function darkenHex(hex, amount = 0.18, satCap = null) { + const r = parseInt(hex.slice(1,3), 16) / 255; + const g = parseInt(hex.slice(3,5), 16) / 255; + const b = parseInt(hex.slice(5,7), 16) / 255; + const max = Math.max(r,g,b), min = Math.min(r,g,b); + let h = 0, s = 0, l = (max+min)/2; + if (max !== min) { + const d = max - min; + s = l > 0.5 ? d/(2-max-min) : d/(max+min); + switch(max) { + case r: h = ((g-b)/d + (g { + if (t<0) t+=1; if (t>1) t-=1; + if (t<1/6) return p+(q-p)*6*t; + if (t<1/2) return q; + if (t<2/3) return p+(q-p)*(2/3-t)*6; + return p; + }; + const q = l < 0.5 ? l*(1+s) : l+s-l*s; + const p = 2*l - q; + const toHex = v => Math.round(hue2rgb(p,q,v)*255).toString(16).padStart(2,'0'); + return `#${toHex(h+1/3)}${toHex(h)}${toHex(h-1/3)}`; +} + +function applyAccent(hex) { + if (!/^#[0-9a-fA-F]{6}$/.test(hex)) return; + // --accent = tint (text/borders); --accent-d = fill (buttons); --accent-rgb tracks the fill. + const fill = darkenHex(hex, 0.26, 0.78); + const r = parseInt(fill.slice(1,3), 16); + const g = parseInt(fill.slice(3,5), 16); + const b = parseInt(fill.slice(5,7), 16); + const root = document.documentElement; + root.style.setProperty('--accent', hex); + root.style.setProperty('--accent-d', fill); + root.style.setProperty('--accent-rgb', `${r}, ${g}, ${b}`); + const swatch = $('accent-swatch'); + const picker = $('s-accent-color'); + const hexEl = $('s-accent-hex'); + if (swatch) swatch.style.background = fill; + if (picker) picker.value = hex; + if (hexEl) hexEl.value = hex.toUpperCase(); +} + +let settingsTimer = null; +let remoteGamePaths = []; +let localBackupPaths = []; + +let _userDirty = false; +let _passDirty = false; +const settingsT = (key, fallback) => typeof window.t === 'function' ? window.t(key, fallback) : fallback; + +function renderConsoleProfiles(packet) { + const select = $('console-profile-active'); + if (!select) return; + select.innerHTML = (packet.profiles || []).map(profile => ``).join(''); + select.value = packet.activeId || ''; + const active = (packet.profiles || []).find(profile => profile.id === packet.activeId); + $('console-profile-status').textContent = active + ? `${active.platform} · Remote Play ${active.secrets?.remotePlayKey ? settingsT('settings.status.linked', 'linked') : settingsT('settings.status.notLinked', 'not linked')} · Sentinel ${active.secrets?.sentinelPairingKey ? settingsT('settings.status.paired', 'paired') : settingsT('settings.status.notPaired', 'not paired')}` + : settingsT('settings.status.noActiveProfile', 'No active profile'); +} + +function renderConsoleIdentity(identity) { + if (!$('settings-identity-profile')) return; + const unavailable = settingsT('settings.identity.unavailable', 'Not observed'); + $('settings-identity-profile').textContent = identity?.profileLabel || unavailable; + $('settings-identity-local').textContent = identity?.local?.username || unavailable; + $('settings-identity-account').textContent = identity?.local?.accountIdMasked || unavailable; + $('settings-identity-psn').textContent = identity?.linkedPsn?.linked && identity.linkedPsn.onlineId ? identity.linkedPsn.onlineId : unavailable; + const observed = identity?.provenance?.observedAt ? new Date(identity.provenance.observedAt).toLocaleString() : settingsT('settings.identity.noTimestamp', 'no timestamp'); + $('settings-identity-evidence').textContent = `${identity?.provenance?.source || 'unknown'} · ${observed}`; +} + +async function loadConsoleProfiles() { + if (!window.pork.consoleProfiles || !$('console-profile-active')) return; + renderConsoleProfiles(await window.pork.consoleProfiles()); + if (window.pork.consoleIdentity) renderConsoleIdentity(await window.pork.consoleIdentity()); +} + +function renderCompanionWorkers(workers) { + const target = $('companion-workers-list'); + if (!target) return; + target.innerHTML = workers.length ? workers.map(worker => `
    ${escHtml(worker.label)}${escHtml(worker.endpoint)} · ${worker.paired ? settingsT('settings.status.paired', 'paired') : settingsT('settings.status.notPaired', 'not paired')}
    `).join('') : `

    ${settingsT('settings.companion.empty', 'No companion workers configured.')}

    `; +} + +async function loadCompanionWorkers() { + if (window.pork.companionWorkers && $('companion-workers-list')) renderCompanionWorkers(await window.pork.companionWorkers()); +} + +async function loadTotpStatus() { + if (!$('totp-settings-status')) return; + if (!window.pork.totpStatus) { + $('totp-settings-status').textContent = settingsT('settings.twoFactor.unavailable', 'Unavailable in this runtime'); + $('totp-settings-begin').disabled = true; + $('totp-settings-disable').hidden = true; + $('passkey-readiness').innerHTML = `${escHtml(settingsT('settings.passkeys.preparedTitle', 'Passkeys: safely prepared, not active yet'))}

    ${escHtml(settingsT('settings.passkeys.runtimeUnavailable', 'This runtime does not expose the required enrollment service.'))}

    `; + return; + } + let status; + try { + status = await Promise.race([ + window.pork.totpStatus(), + new Promise((_, reject) => setTimeout(() => reject(new Error(settingsT('settings.twoFactor.timeout', 'Status check timed out'))), 3500)), + ]); + } catch (error) { + $('totp-settings-status').textContent = settingsT('settings.twoFactor.unavailable', 'Temporarily unavailable'); + $('totp-settings-status').title = error.message; + $('totp-settings-begin').disabled = true; + return; + } + $('totp-settings-begin').disabled = false; + $('totp-settings-status').textContent = status.enabled + ? `${settingsT('settings.status.active', 'Active')} · ${status.recoveryCodesRemaining} ${settingsT('settings.twoFactor.codesRemaining', 'recovery codes remaining')}` + : status.pending ? settingsT('settings.twoFactor.pending', 'Pairing started but not confirmed') : settingsT('settings.status.inactive', 'Inactive'); + $('totp-settings-begin').textContent = status.enabled ? settingsT('settings.twoFactor.repair', 'Pair authenticator again') : settingsT('settings.twoFactor.pair', 'Pair authenticator'); + $('totp-settings-disable').hidden = !status.enabled; + $('passkey-readiness').innerHTML = `Passkeys: ${status.passkeys?.available ? settingsT('settings.status.available', 'available') : settingsT('settings.passkeys.prepared', 'safely prepared, not active yet')}

    ${escHtml(status.passkeys?.reason || settingsT('settings.status.none', 'No status.'))}

    `; +} + +async function loadGenericNotificationStatus() { + if (!window.pork.genericNotificationStatus || !$('generic-notification-status')) return; + const status = await window.pork.genericNotificationStatus(); + $('generic-notification-enabled').checked = status.enabled; + $('generic-notification-endpoint').value = ''; + $('generic-notification-endpoint').placeholder = status.endpointConfigured ? `${settingsT('settings.notifications.configuredFor', 'Configured for')} ${status.endpointHost}; ${settingsT('settings.notifications.leaveBlank', 'leave blank to keep')}` : 'https://notify.example/chimera'; + $('generic-notification-token').value = ''; + $('generic-notification-cooldown').value = status.cooldownMinutes || 60; + $('generic-notification-rules').innerHTML = (status.availableRules || []).map(rule => ``).join(''); + $('generic-notification-status').textContent = `${status.format} · endpoint ${status.endpointConfigured ? status.endpointHost : settingsT('settings.status.notConfigured', 'not configured')} · bearer ${status.bearerConfigured ? settingsT('settings.status.encrypted', 'configured and encrypted') : settingsT('settings.status.notConfigured', 'not configured')}`; +} + +function renderConsoleComparison(packet) { + const target = $('console-profiles-comparison'); + target.hidden = false; + target.innerHTML = `
    ${(packet.profiles || []).map(profile => `
    ${profile.active ? '● ' : ''}${escHtml(profile.label)}${escHtml(profile.host)} · firmware ${escHtml(profile.firmware)} · ${escHtml(profile.hen)} ${escHtml(profile.henVersion)}
    Remote Play ${profile.pairing?.remotePlay ? settingsT('settings.status.linked', 'linked') : settingsT('settings.status.notLinked', 'not linked')} · Sentinel ${profile.pairing?.sentinel ? settingsT('settings.status.paired', 'paired') : settingsT('settings.status.notPaired', 'not paired')} · ${settingsT('settings.status.storage', 'storage')} ${escHtml(profile.storage?.status || 'unknown')}
    `).join('')}
    `; +} + +$('console-profile-activate')?.addEventListener('click', async event => { + try { + const result = await window.pork.consoleProfileActivate($('console-profile-active').value); + renderConsoleProfiles(result); + await loadSettings(); + showToast('Console profile activated; no automatic connection was started.', 'ok', 7000); + } catch (error) { showToast(error.message, 'error'); } +}); + +$('console-profile-new')?.addEventListener('submit', async event => { + event.preventDefault(); + try { + const result = await window.pork.consoleProfileSave({ label: $('console-profile-new-label').value.trim(), host: $('console-profile-new-host').value.trim(), makeActive: false }); + renderConsoleProfiles(result); + event.target.reset(); + showToast(settingsT('settings.consoleProfiles.added', 'Console profile added without starting a connection.'), 'ok'); + } catch (error) { showToast(error.message, 'error'); } +}); + +$('console-profiles-compare')?.addEventListener('click', async event => { + try { renderConsoleComparison(await window.pork.consoleProfilesCompare()); } + catch (error) { showToast(error.message, 'error'); } +}); + +$('companion-worker-form')?.addEventListener('submit', async event => { + event.preventDefault(); + try { + renderCompanionWorkers(await window.pork.companionWorkerSave({ id: $('companion-worker-id').value.trim(), label: $('companion-worker-label').value.trim(), endpoint: $('companion-worker-endpoint').value.trim() })); + event.target.reset(); showToast('Companion worker saved; no job was started.', 'ok'); + } catch (error) { showToast(error.message, 'error'); } +}); + +$('companion-workers-list')?.addEventListener('click', async event => { + const row = event.target.closest('[data-worker-id]'); if (!row) return; const id = row.dataset.workerId; + try { + if (event.target.closest('.companion-worker-test')) { + const status = await window.pork.companionWorkerCapabilities(id); + showToast(`Worker verified: ${status.capabilities.join(', ') || 'no supported capabilities'}; shell disabled.`, 'ok', 9000); + } + if (event.target.closest('.companion-worker-pair')) { + const workers = await window.pork.companionWorkers(); const rotate = workers.find(worker => worker.id === id)?.paired === true; + const phrase = `PAIR WORKER ${id}`; const confirmation = await showExactConfirmation(`${rotate ? 'Rotate' : 'Create'} the pairing key for this worker?`, phrase); if (!confirmation) return; + const result = await window.pork.companionWorkerPair({ id, rotate, confirmation }); + if (result.pairingKey) { const output = $('companion-worker-pairing-output'); output.hidden = false; output.textContent = `Worker: ${id}\nPairing key (eenmalig zichtbaar): ${result.pairingKey}\n\nImporteer lokaal in Chimera Worker en wis deze weergave.`; } + await loadCompanionWorkers(); + } + if (event.target.closest('.companion-worker-remove') && await showExactConfirmation(`Remove ${id} and erase its pairing key?`, 'DELETE')) renderCompanionWorkers(await window.pork.companionWorkerRemove(id)); + } catch (error) { showToast(error.message, 'error', 10000); } +}); + +$('totp-settings-begin')?.addEventListener('click', async event => { + if (!await showConfirm('Pair a new authenticator? TOTP becomes active only after you confirm a valid code.')) return; + try { + const enrollment = await window.pork.totpBegin(); const output = $('totp-settings-output'); output.hidden = false; output.textContent = `One-time TOTP secret: ${enrollment.secret}\n\nAuthenticator URI:\n${enrollment.uri}`; + const code = await showPrompt('Add the secret or URI to your authenticator, then enter the current 6-digit code:'); if (code == null) return; + const confirmed = await window.pork.totpConfirm(code); output.textContent = `TOTP is active. Save these recovery codes now; they will not be shown again:\n\n${confirmed.recoveryCodes.join('\n')}`; + await loadTotpStatus(); showToast('Two-factor authentication is active.', 'ok', 8000); + } catch (error) { showToast(error.message, 'error', 10000); } +}); + +$('totp-settings-disable')?.addEventListener('click', async () => { + const code = await showPrompt('Enter a valid authenticator or recovery code to disable TOTP:'); if (code == null) return; + if (!await showExactConfirmation('Disable two-factor authentication?', 'UITSCHAKELEN')) return; + try { await window.pork.totpDisable(code); $('totp-settings-output').hidden = true; await loadTotpStatus(); showToast('Two-factor authentication disabled.', 'ok'); } + catch (error) { showToast(error.message, 'error'); } +}); + +$('generic-notification-form')?.addEventListener('submit', async event => { + event.preventDefault(); + try { + const token = $('generic-notification-token').value; + const endpoint = $('generic-notification-endpoint').value.trim(); + const rules = [...document.querySelectorAll('[data-notification-rule]:checked')].map(input => input.dataset.notificationRule); + await window.pork.genericNotificationSave({ enabled: $('generic-notification-enabled').checked, rules, cooldownMinutes: Number($('generic-notification-cooldown').value), ...(endpoint ? { endpoint } : {}), ...(token ? { bearerToken: token } : {}) }); + await loadGenericNotificationStatus(); showToast('Generic notification settings saved.', 'ok'); + } catch (error) { showToast(error.message, 'error'); } +}); + +$('generic-notification-test')?.addEventListener('click', async event => { + try { await window.pork.genericNotificationTest(); showToast('Test notification delivered.', 'ok'); } + catch (error) { showToast(error.message, 'error'); } +}); + +$('settings-automation-analyze')?.addEventListener('click', async event => { + const button = event.currentTarget; + button.disabled = true; + try { + const [sources, protection] = await Promise.all([window.pork.automationSourcesAnalyze(), window.pork.automationProtectionStatus()]); + $('settings-automation-analysis').innerHTML = ` +
    +
    ${sources.roots.length}roots inspected
    +
    ${sources.recommendations.length}high-confidence suggestions
    +
    ${protection.saveVault.eligible ? 'Ready' : 'Setup'}Save Vault
    +
    + ${(sources.roots || []).map(item => `
    ${escHtml(item.root)}${escHtml(item.classification)} · ${escHtml(item.confidence)} confidence · ${item.writable ? 'writable' : 'read-only/unavailable'}
    ${escHtml(item.exists ? 'inspected' : item.error || 'missing')}
    `).join('')} +

    No configuration changes were applied. Enable Save Vault, replication and restore drills from Settings → Automation after reviewing their targets.

    `; + } catch (error) { showToast(error.message, 'error'); } + finally { button.disabled = false; } +}); + +function scheduleSettingsSave() { + clearTimeout(settingsTimer); + settingsTimer = setTimeout(commitSettingsSave, 600); +} + +let _webhookDirty = false; +$('s-discord-webhook')?.addEventListener('input', () => { _webhookDirty = true; }); + +async function commitSettingsSave() { + const payload = { + ftpHost: $('s-host').value.trim(), + ftpPorts: $('s-ports').value.split(',').map(p => p.trim()).filter(Boolean), + backupPaths: localBackupPaths, + backupPath: localBackupPaths[0] || '', + payloadLocalPath: $('s-payload-local').value.trim(), + payloadRemotePath:$('s-payload-remote').value.trim(), + cheatsRemotePath: $('s-cheats-remote').value.trim(), + mediaPsNotifyEnabled: $('s-media-discord-notify').checked, + autoConnect: $('s-auto-connect').checked, + timeFormat: $('s-time-format').value, + hashConcurrency: Number($('s-hash-concurrency')?.value) || 8, + }; + if (_userDirty) payload.ftpUser = $('s-user').value.trim(); + if (_passDirty) payload.ftpPass = $('s-pass').value; + if (_webhookDirty) payload.discordWebhookUrl = $('s-discord-webhook').value.trim(); + await window.pork.setSettings(payload); + _passDirty = false; + _webhookDirty = false; + $('d-host').value = $('s-host').value; + $('d-ports').value = $('s-ports').value; + setStatus('Settings saved', 'ok'); +} + +async function loadSettings(options = {}) { + const background = !!options.background; + const s = await window.pork.getSettings(); + $('s-host').value = s.ftpHost || ''; + $('s-ports').value = (s.ftpPorts && s.ftpPorts.length) ? s.ftpPorts.join(', ') : '1337, 2121, 21'; + localBackupPaths = s.backupPaths || (s.backupPath ? [s.backupPath] : []); + renderLocalBackupPaths(); + $('s-payload-local').value = s.payloadLocalPath || ''; + $('s-payload-remote').value = s.payloadRemotePath || '/data/payloads/'; + $('s-cheats-remote').value = s.cheatsRemotePath || '/data/etaHEN/cheats/'; + $('s-discord-webhook').value = ''; + $('s-discord-webhook').placeholder = s.discordWebhookConfigured ? 'Stored securely — enter to replace' : 'https://discord.com/api/webhooks/...'; + $('s-media-discord-notify').checked = s.mediaPsNotifyEnabled !== false; // default true + $('media-discord-psn-detail').hidden = s.mediaPsNotifyEnabled === false; + $('s-media-psn-download').checked = s.mediaPsNotifyDiscordDownload !== false; + $('s-media-psn-convert').checked = s.mediaPsNotifyDiscordConvert !== false; + $('s-media-psn-upload').checked = s.mediaPsNotifyDiscordUpload !== false; + $('s-media-psn-done').checked = s.mediaPsNotifyDiscordDone !== false; + $('s-media-psn-error').checked = s.mediaPsNotifyDiscordError !== false; + $('s-auto-connect').checked = s.autoConnect === true; + _timeFormat = s.timeFormat || '12h'; + $('s-time-format').value = _timeFormat; + if ($('s-interface-density')) $('s-interface-density').value = applyDensity(s.interfaceDensity); + if (!background) { + $('d-host').value = s.ftpHost || ''; + $('d-ports').value = (s.ftpPorts && s.ftpPorts.length) ? s.ftpPorts.join(', ') : '1337, 2121, 21'; + } + applyAccent(s.accentColor || '#CBBAFF'); + remoteGamePaths = s.remoteGamePaths || []; + renderRemoteGamePaths(); + await renderSettingsPayloadSources(); + if (s.svHotkeys) _svHotkeys = { ..._svHotkeys, ...s.svHotkeys }; + const hkMap = { 'hk-mute': 'mute', 'hk-gif': 'gif', 'hk-video': 'video', 'hk-fullscreen': 'fullscreen', 'hk-popout': 'popout' }; + Object.entries(hkMap).forEach(([id, action]) => { + const el = $(id); if (el) el.value = _svHotkeys[action] || ''; + }); + + let convMode = s.gameConversionMode || 'pfs'; + if (window.pork.isWebMode) { + for (const unavailable of ['ffpkg', 'exfat']) { + const radio = document.querySelector(`input[name="conv-mode"][value="${unavailable}"]`); + if (radio) radio.disabled = true; + radio?.closest('.radio-card')?.setAttribute('title', settingsT('settings.windowsModeUnavailable', 'This Windows mode is unavailable in the Unraid container. Use FFPFSC.')); + const queueButton = document.querySelector(`.conv-mode-btn[data-conv-mode="${unavailable}"]`); + if (queueButton) queueButton.disabled = true; + } + if (!['pfs', 'ffpfsc'].includes(convMode)) { + convMode = 'ffpfsc'; + await window.pork.setSettings({ gameConversionMode: convMode }); + } + } + const modeRadio = document.querySelector(`input[name="conv-mode"][value="${convMode}"]`); + if (modeRadio) modeRadio.checked = true; + applyConvModeUI(convMode); + + $('s-conv-ufs2-path').value = s.ufs2ToolPath || ''; + $('s-conv-exfat-path').value = s.osfmountPath || s.exfatToolPath || ''; + $('s-conv-output-dir').value = s.convOutputDir || ''; + $('s-conv-temp-dir').value = s.convTempDir || ''; + $('s-conv-ftp-upload').checked = !!s.convFtpUpload; + $('s-conv-delete-after').checked = !!s.convDeleteAfter; + $('s-ps4-package-builder').value = s.ps4PackageBuilderPath || ''; + $('s-ps4-package-output').value = s.ps4PackageOutputPath || ''; + window.pork.ps4PackageBuilderStatus().then(status => { + $('s-ps4-package-status').textContent = status.available ? settingsT('ps4Packages.settings.ready', 'Ready') : settingsT('ps4Packages.settings.blocked', 'Not configured'); + $('s-ps4-package-status').className = `badge ${status.available ? 'badge-green' : 'badge-orange'}`; + }).catch(error => { $('s-ps4-package-status').textContent = error.message; }); + $('s-ps5-native-package-builder').value = s.ps5NativePackageBuilderPath || ''; + $('s-ps5-native-package-output').value = s.ps5NativePackageOutputPath || ''; + window.pork.ps5NativePackageBuilderStatus().then(status => { + $('s-ps5-native-package-status').textContent = status.available ? settingsT('ps5NativePackages.settings.ready', 'Pinned bridge ready') : settingsT('ps5NativePackages.settings.blocked', 'Research bridge not configured'); + $('s-ps5-native-package-status').className = `badge ${status.available ? 'badge-green' : 'badge-orange'}`; + }).catch(error => { $('s-ps5-native-package-status').textContent = error.message; }); + $('conv-delete-after-row').hidden = false; + $('s-conv-delete-after').disabled = !s.convFtpUpload; + $('conv-delete-after-row').style.opacity = s.convFtpUpload ? '' : '0.45'; + + $('s-conv-psn-enabled').checked = s.convPsNotifyEnabled !== false; + $('conv-psn-detail').hidden = !s.convPsNotifyEnabled; + $('s-conv-psn-queued').checked = s.convPsNotifyOnGameQueued !== false; + $('s-conv-psn-batch').checked = s.convPsNotifyOnBatchQueued !== false; + $('s-conv-psn-copy').checked = s.convPsNotifyOnCopyStart !== false; + $('s-conv-psn-convert').checked = s.convPsNotifyOnConvertStart!== false; + $('s-conv-psn-done').checked = s.convPsNotifyOnJobDone !== false; + + await refreshConvToolStatus(); + + if ($('s-hash-concurrency')) $('s-hash-concurrency').value = s.hashConcurrency || 8; + if ($('s-webui-port')) $('s-webui-port').value = s.webUiPort || 6967; + if (window.pork.isWebMode) { + $('s-webui-enabled').disabled = true; + $('s-webui-port').disabled = true; + $('btn-webui-apply').disabled = true; + $('s-webui-enabled').closest('.toggle-row')?.setAttribute('title', settingsT('settings.serverManagedByUnraid', 'This web server is managed as an Unraid container.')); + } + await refreshWebuiStatus(); + +const _langCode = s.language || 'en'; + try { + await refreshLangDropdown(_langCode); + } catch (_) {} + await window.i18n.load(_langCode); + await Promise.all([ + loadConsoleProfiles(), + loadCompanionWorkers(), + loadTotpStatus(), + loadGenericNotificationStatus(), + ]); +} + +['s-host', 's-ports', 's-hash-concurrency'].forEach(id => { + const el = $(id); + if (el) el.addEventListener('input', scheduleSettingsSave); +}); + +async function refreshLangDropdown(selectedCode) { + const langs = await window.pork.langList(); + const langSel = $('s-language'); + langSel.innerHTML = langs.map(l => { + const label = l.source === 'imported' ? `${escHtml(l.name)} ✦` : escHtml(l.name); + return ``; + }).join(''); +} + +$('s-language').addEventListener('change', async function() { + const code = this.value; + await window.pork.setSettings({ language: code }); + await window.i18n.load(code); + await Promise.all([ + loadConsoleProfiles(), + loadCompanionWorkers(), + loadTotpStatus(), + loadGenericNotificationStatus(), + ]); + setStatus(window.t('status.languageChanged', 'Language changed'), 'ok'); +}); + +$('s-interface-density')?.addEventListener('change', async function() { + const interfaceDensity = applyDensity(this.value); + await window.pork.setSettings({ interfaceDensity }); + setStatus(window.t('settings.density.saved', 'Interface density updated.'), 'ok'); +}); + +$('btn-import-lang').addEventListener('click', async () => { + const btn = $('btn-import-lang'); + btn.disabled = true; + try { + const res = await window.pork.langImport(); + if (res.canceled) return; + const currentCode = $('s-language').value; + await refreshLangDropdown(currentCode); + setStatus(`${window.t('lang.importDone', 'Language imported')}: ${res.name}`, 'ok'); + if (res.code === currentCode) await window.i18n.load(res.code); + } catch (e) { + setStatus(`${window.t('lang.importError', 'Import failed')}: ${e.message}`, 'error'); + } finally { + btn.disabled = false; + } +}); + +$('btn-export-lang').addEventListener('click', async () => { + const btn = $('btn-export-lang'); + btn.disabled = true; + try { + const res = await window.pork.langExportBase(); + if (!res.canceled) setStatus(window.t('lang.exportDone', 'English base file exported.'), 'ok'); + } catch (e) { + setStatus(`${window.t('lang.exportError', 'Export failed')}: ${e.message}`, 'error'); + } finally { + btn.disabled = false; + } +}); +$('d-host').addEventListener('input', () => { + $('s-host').value = $('d-host').value; + scheduleSettingsSave(); +}); +$('d-ports').addEventListener('input', () => { + $('s-ports').value = $('d-ports').value; + scheduleSettingsSave(); +}); +$('s-user').addEventListener('input', () => { _userDirty = true; scheduleSettingsSave(); }); +$('s-pass').addEventListener('input', () => { _passDirty = true; scheduleSettingsSave(); }); + + +function renderLocalBackupPaths() { + const cont = $('settings-backup-paths-list'); + if (!cont) return; + if (!localBackupPaths.length) { + cont.innerHTML = '

    No folders added yet. Click “+ Add Folder…” to get started.

    '; + return; + } + cont.innerHTML = localBackupPaths.map((p, i) => ` +
    + ${escHtml(p)} + +
    + `).join(''); +} + +async function saveLocalBackupPaths() { + await window.pork.setSettings({ + backupPaths: localBackupPaths, + backupPath: localBackupPaths[0] || '', + }); + setStatus('Settings saved', 'ok'); +} + +$('settings-backup-paths-list').addEventListener('click', async e => { + const row = e.target.closest('[data-backup-idx]'); + if (!row || !e.target.closest('.btn-backup-path-remove')) return; + const idx = Number(row.dataset.backupIdx); + const ok = await showConfirm(`Remove "${escHtml(localBackupPaths[idx])}" from game source folders?`); + if (!ok) return; + localBackupPaths = localBackupPaths.filter((_, i) => i !== idx); + await saveLocalBackupPaths(); + renderLocalBackupPaths(); +}); + +$('btn-add-backup-path').addEventListener('click', async () => { + const folder = await window.pork.selectFolder(); + if (!folder) return; + if (localBackupPaths.includes(folder)) { setStatus('That folder is already in the list', 'ok'); return; } + localBackupPaths = [...localBackupPaths, folder]; + await saveLocalBackupPaths(); + renderLocalBackupPaths(); +}); + +$('btn-browse-payload-local').addEventListener('click', async () => { + const folder = await window.pork.selectFolder(); + if (!folder) return; + $('s-payload-local').value = folder; + await commitSettingsSave(); +}); + +$('s-payload-remote').addEventListener('input', scheduleSettingsSave); + +$('s-cheats-remote').addEventListener('input', scheduleSettingsSave); + +$('s-discord-webhook').addEventListener('input', scheduleSettingsSave); + +$('btn-browse-cheats-remote').addEventListener('click', async () => { + const cur = $('s-cheats-remote').value.trim() || '/'; + const p = await openFtpBrowser(cur); + if (p) { $('s-cheats-remote').value = p; scheduleSettingsSave(); } +}); + +$('s-auto-connect').addEventListener('change', commitSettingsSave); + +$('s-time-format').addEventListener('change', e => { + _timeFormat = e.target.value; + commitSettingsSave(); + renderMediaGrid(); +}); + +$('s-media-discord-notify').addEventListener('change', function() { + $('media-discord-psn-detail').hidden = !this.checked; + commitSettingsSave(); +}); + +[ + ['s-media-psn-download', 'mediaPsNotifyDiscordDownload'], + ['s-media-psn-convert', 'mediaPsNotifyDiscordConvert'], + ['s-media-psn-upload', 'mediaPsNotifyDiscordUpload'], + ['s-media-psn-done', 'mediaPsNotifyDiscordDone'], + ['s-media-psn-error', 'mediaPsNotifyDiscordError'], +].forEach(([id, key]) => { + $(id).addEventListener('change', function() { + window.pork.setSettings({ [key]: this.checked }); + }); +}); + + +function applyConvModeUI(mode) { + const isPfs = mode === 'pfs'; + const isFfpkg = mode === 'ffpkg'; + const isExfat = mode === 'exfat'; + const isConv = !isPfs; + + $('conv-ffpkg-fields').hidden = !isFfpkg; + $('conv-exfat-fields').hidden = !isExfat; + $('conv-shared-fields').hidden = !isConv; + $('conv-notify-fields').hidden = !isConv; + + const badge = $('conv-queue-mode-badge'); + if (badge) badge.textContent = isPfs ? 'Raw Dumps' : isFfpkg ? 'FFPKG' : isExfat ? 'ExFAT' : 'FFPFSC'; +} + +async function refreshConvToolStatus() { + const mode = document.querySelector('input[name="conv-mode"]:checked')?.value || 'pfs'; + if (mode === 'ffpkg') { + try { + const info = await window.pork.convToolInfo(); + const el = $('s-conv-ufs2-status'); + if (!el) return; + if (info.toolAvailable) { + el.textContent = '✓ UFS2Tool.exe found'; + el.style.color = 'var(--green)'; + } else { + el.textContent = info.toolPath ? '✗ File not found at configured path' : '✗ Not configured'; + el.style.color = 'var(--red)'; + } + } catch (_) {} + } + if (mode === 'exfat') { + try { + const info = await window.pork.convExfatToolInfo(); + const el = $('s-conv-exfat-status'); + if (!el) return; + if (info.toolAvailable) { + el.textContent = `✓ OSFMount found — native exFAT (${info.osfmountPath || info.toolPath})`; + el.style.color = 'var(--green)'; + } else { + el.textContent = '✗ OSFMount not found — install OSFMount or pick osfmount.com'; + el.style.color = 'var(--red)'; + } + } catch (_) {} + } +} + +document.querySelectorAll('input[name="conv-mode"]').forEach(radio => { + radio.addEventListener('change', async () => { + const mode = radio.value; + applyConvModeUI(mode); + await window.pork.setSettings({ gameConversionMode: mode }); + await refreshConvToolStatus(); + setStatus('Conversion mode saved', 'ok'); + }); +}); + +document.querySelectorAll('input[name="ufs2-method"]').forEach(radio => { + radio.addEventListener('change', async () => { + await window.pork.setSettings({ ufs2Method: radio.value }); + setStatus('UFS2 method saved', 'ok'); + }); +}); + +$('btn-conv-ufs2-pick').addEventListener('click', async () => { + const info = await window.pork.convToolPick(); + if (!info) return; + $('s-conv-ufs2-path').value = info.toolPath || ''; + const el = $('s-conv-ufs2-status'); + if (el) { + el.textContent = info.toolAvailable ? '✓ UFS2Tool.exe found' : '✗ File not found'; + el.style.color = info.toolAvailable ? 'var(--green)' : 'var(--red)'; + } + setStatus('UFS2Tool path saved', 'ok'); +}); + +$('btn-conv-exfat-pick').addEventListener('click', async () => { + const info = await window.pork.convExfatToolPick(); + if (!info) return; + $('s-conv-exfat-path').value = info.toolPath || ''; + await refreshConvToolStatus(); + setStatus('ExFAT tool path saved', 'ok'); +}); + +$('btn-conv-output-pick').addEventListener('click', async () => { + const p = await window.pork.convPickOutputDir(); + if (!p) return; + $('s-conv-output-dir').value = p; + setStatus('Output directory saved', 'ok'); +}); + +$('btn-conv-temp-pick').addEventListener('click', async () => { + const p = await window.pork.convPickTempDir(); + if (!p) return; + $('s-conv-temp-dir').value = p; + setStatus('Temp directory saved', 'ok'); +}); + +$('s-conv-ftp-upload').addEventListener('change', async function() { + const ftpEnabled = this.checked; + $('s-conv-delete-after').disabled = !ftpEnabled; + $('conv-delete-after-row').style.opacity = ftpEnabled ? '' : '0.45'; + if (!ftpEnabled) { + $('s-conv-delete-after').checked = false; + await window.pork.setSettings({ convFtpUpload: false, convDeleteAfter: false }); + } else { + await window.pork.setSettings({ convFtpUpload: true }); + } + setStatus('Settings saved', 'ok'); +}); + +$('s-conv-delete-after').addEventListener('change', async function() { + await window.pork.setSettings({ convDeleteAfter: this.checked }); + setStatus('Settings saved', 'ok'); +}); + +$('btn-ps4-package-builder').addEventListener('click', async () => { + const folder = await window.pork.selectFolder(); if (!folder) return; + await window.pork.setSettings({ ps4PackageBuilderPath: folder }); + $('s-ps4-package-builder').value = folder; + const status = await window.pork.ps4PackageBuilderStatus(); + $('s-ps4-package-status').textContent = status.available ? settingsT('ps4Packages.settings.ready', 'Ready') : settingsT('ps4Packages.settings.blocked', 'Not configured'); +}); +$('btn-ps4-package-output').addEventListener('click', async () => { + const folder = await window.pork.selectFolder(); if (!folder) return; + await window.pork.setSettings({ ps4PackageOutputPath: folder }); + $('s-ps4-package-output').value = folder; + showToast(settingsT('ps4Packages.settings.restart', 'Restart Chimera to activate the new package vault.'), 'info'); +}); +$('btn-ps5-native-package-builder').addEventListener('click', async () => { + const folder = await window.pork.selectFolder(); if (!folder) return; + await window.pork.setSettings({ ps5NativePackageBuilderPath: folder }); + $('s-ps5-native-package-builder').value = folder; + const status = await window.pork.ps5NativePackageBuilderStatus(); + $('s-ps5-native-package-status').textContent = status.available ? settingsT('ps5NativePackages.settings.ready', 'Pinned bridge ready') : settingsT('ps5NativePackages.settings.blocked', 'Research bridge not configured'); +}); +$('btn-ps5-native-package-output').addEventListener('click', async () => { + const folder = await window.pork.selectFolder(); if (!folder) return; + await window.pork.setSettings({ ps5NativePackageOutputPath: folder }); + $('s-ps5-native-package-output').value = folder; + showToast(settingsT('ps5NativePackages.settings.restart', 'Restart Chimera to activate the native package vault.'), 'info'); +}); +$('s-conv-psn-enabled').addEventListener('change', async function() { + $('conv-psn-detail').hidden = !this.checked; + await window.pork.setSettings({ convPsNotifyEnabled: this.checked }); + setStatus('Settings saved', 'ok'); +}); + +[ + ['s-conv-psn-queued', 'convPsNotifyOnGameQueued'], + ['s-conv-psn-batch', 'convPsNotifyOnBatchQueued'], + ['s-conv-psn-copy', 'convPsNotifyOnCopyStart'], + ['s-conv-psn-convert', 'convPsNotifyOnConvertStart'], + ['s-conv-psn-done', 'convPsNotifyOnJobDone'], +].forEach(([id, key]) => { + $(id).addEventListener('change', function() { + window.pork.setSettings({ [key]: this.checked }); + }); +}); + +$('settings-ftp-presets').addEventListener('click', e => { + const btn = e.target.closest('[data-add-port]'); + if (!btn) return; + const port = btn.dataset.addPort; + const current = $('s-ports').value.split(',').map(p => p.trim()).filter(Boolean); + if (!current.includes(port)) { + current.push(port); + $('s-ports').value = current.join(', '); + commitSettingsSave(); + } +}); + +{ + const hkMap = { 'hk-mute': 'mute', 'hk-gif': 'gif', 'hk-video': 'video', 'hk-fullscreen': 'fullscreen', 'hk-popout': 'popout' }; + Object.entries(hkMap).forEach(([id, action]) => { + const input = $(id); + if (!input) return; + input.addEventListener('focus', () => { + input.dataset.prev = input.value; + input.value = ''; + input.placeholder = 'press a key…'; + input.classList.add('capturing'); + }); + input.addEventListener('blur', () => { + if (!input.value) input.value = input.dataset.prev || ''; + input.placeholder = '—'; + input.classList.remove('capturing'); + }); + input.addEventListener('keydown', async e => { + e.preventDefault(); + if (e.key === 'Escape') { input.value = input.dataset.prev || ''; input.blur(); return; } + const key = e.key.length === 1 ? e.key.toLowerCase() : e.key; + input.value = key; + _svHotkeys[action] = key; + input.blur(); + await window.pork.setSettings({ svHotkeys: { ..._svHotkeys } }); + setStatus('Hotkey saved', 'ok'); + }); + }); +} + + +function renderRemoteGamePaths() { + const cont = $('settings-remote-paths-list'); + if (!remoteGamePaths.length) { + cont.innerHTML = '

    No paths configured. Scan will use standard PS5 paths.

    '; + return; + } + cont.innerHTML = remoteGamePaths.map((p, i) => ` +
    + ${escHtml(p.label)} + ${escHtml(p.path)} + +
    + `).join(''); +} + +async function saveRemoteGamePaths() { + await window.pork.setSettings({ remoteGamePaths }); + setStatus('Settings saved', 'ok'); +} + +$('btn-add-remote-path').addEventListener('click', async () => { + const label = $('new-remote-label').value.trim(); + const path = $('new-remote-path').value.trim(); + if (!path) { setStatus('Enter a remote path', 'error'); return; } + remoteGamePaths = [...remoteGamePaths, { label: label || path, path }]; + await saveRemoteGamePaths(); + renderRemoteGamePaths(); + $('new-remote-label').value = ''; + $('new-remote-path').value = ''; +}); + +$('settings-remote-paths-list').addEventListener('click', async e => { + const row = e.target.closest('[data-idx]'); + if (!row || !e.target.closest('.btn-remote-path-remove')) return; + const idx = Number(row.dataset.idx); + const ok = await showConfirm(`Remove "${escHtml(remoteGamePaths[idx]?.label)}" from remote game paths?`); + if (!ok) return; + remoteGamePaths = remoteGamePaths.filter((_, i) => i !== idx); + await saveRemoteGamePaths(); + renderRemoteGamePaths(); +}); + + +async function refreshWebuiStatus() { + try { + const status = await window.pork.webuiStatus(); + const badge = $('webui-status-badge'); + const urlRow = $('webui-url-row'); + const urlEl = $('webui-url'); + const toggle = $('s-webui-enabled'); + + if (badge) badge.hidden = !status.running; + if (toggle) toggle.checked = status.running; + + if (urlRow && urlEl) { + if (status.running) { + const port = status.port; + const hosts = Array.isArray(status.hosts) && status.hosts.length ? status.hosts : ['localhost']; + const primary = (window.pork.isWebMode && location.hostname) + ? location.hostname + : hosts[0]; + const primaryUrl = status.publicOrigin || (window.pork.isWebMode ? location.origin : `http://${primary}:${port}`); + urlEl.textContent = primaryUrl; + urlRow.hidden = false; + const openBtn = $('btn-webui-open'); + if (openBtn) openBtn.onclick = () => window.pork.openShell ? window.pork.openShell(primaryUrl) : window.open(primaryUrl, '_blank'); + } else { + urlRow.hidden = true; + } + } + } catch (_) {} +} + +$('s-webui-enabled').addEventListener('change', async function () { + const btn = $('btn-webui-apply'); + if (btn) btn.disabled = true; + try { + if (this.checked) { + const port = Number($('s-webui-port').value) || 6967; + await window.pork.setSettings({ webUiEnabled: true, webUiPort: port }); + const res = await window.pork.webuiStart(); + if (!res.ok) { setStatus(`Web UI failed: ${res.error}`, 'error'); this.checked = false; } + else setStatus('Web UI server started', 'ok'); + } else { + await window.pork.setSettings({ webUiEnabled: false }); + const res = await window.pork.webuiStop(); + if (res.ok) setStatus('Web UI server stopped', 'ok'); + } + } catch (e) { + setStatus(`Web UI error: ${e.message}`, 'error'); + } finally { + if (btn) btn.disabled = false; + await refreshWebuiStatus(); + } +}); + +$('btn-webui-apply').addEventListener('click', async () => { + const port = Number($('s-webui-port').value) || 6967; + await window.pork.setSettings({ webUiPort: port }); + if ($('s-webui-enabled').checked) { + await window.pork.webuiStop(); + const res = await window.pork.webuiStart(); + if (res.ok) setStatus(`Web UI restarted on port ${port}`, 'ok'); + else setStatus(`Web UI restart failed: ${res.error}`, 'error'); + } else { + setStatus('Port saved', 'ok'); + } + await refreshWebuiStatus(); +}); + diff --git a/renderer/modules/state.js b/renderer/modules/state.js new file mode 100644 index 0000000..b78e0b0 --- /dev/null +++ b/renderer/modules/state.js @@ -0,0 +1,485 @@ +'use strict'; + +// State +const state = { + connected: false, + games: [], + backups: [], + sortCol: localStorage.getItem('chimera.games.sortCol') || 'title', + sortDir: localStorage.getItem('chimera.games.sortDir') || 'asc', + filter: localStorage.getItem('chimera.games.filter') || 'all', + platformFilter: ['all', 'ps4', 'ps5', 'unknown'].includes(localStorage.getItem('chimera.games.platform')) + ? localStorage.getItem('chimera.games.platform') + : 'all', + search: '', + firmwareFilter: 'all', + gameView: localStorage.getItem('chimera.games.view') || 'grid', + gameRenderLimit: 160, + currentGame: null, +}; + +// Shared hash summary maps — populated by loadGames() and loadBackporks(). +// _gameHashSummary: game_id → summary for hash_type='game' +// _backporkHashSummary: 'game_id|firmware_label' → summary for hash_type='backpork' +const _gameHashSummary = new Map(); +const _backporkHashSummary = new Map(); +const _pageLoadTimestamps = new Map(); +const _pageLoadModes = new Map(); +const _pageLoadInFlight = new Map(); +let _pageWarmTimer = null; +let _pageWarmRunning = false; + +// Feature code is route-loaded. Background refreshes only revisit modules that a user +// already opened; they never download the entire application after startup. +const _PAGE_WARM_ORDER = []; + +const _PAGE_STALE_MS = { + dashboard: 15_000, + transfers: 5_000, + 'system-view': 20_000, + media: 30_000, + default: 45_000, +}; + +function _populateHashMaps(summaries) { + _gameHashSummary.clear(); + _backporkHashSummary.clear(); + for (const s of summaries) { + if (s.hash_type === 'backpork') { + _backporkHashSummary.set(s.game_id + '|' + (s.firmware_label || ''), s); + } else { + _gameHashSummary.set(s.game_id, s); + } + } +} + +// System View state +let _svStream = null; +let _svMuted = false; +let _svAudioCtx = null; // AudioContext for low-latency capture-card audio +let _svAudioGain = null; // GainNode — controls mute without stopping the stream +let _svHotkeys = { mute: 'm', gif: 'g', video: 'v', fullscreen: 'f', popout: 'p' }; +let _svRecording = false; +let _svRecFrames = []; // Uint8ClampedArray[] — one per captured frame +let _svRecInterval = null; +let _svRecTimer = null; +let _svRecStart = 0; +let _svRecCanvas = null; // lazy-created hidden 640×360 canvas +let _svRecCtx = null; +const GIF_W = 640, GIF_H = 360; + +// Video recording state +let _svVidRecorder = null; +let _svVidWritable = null; +let _svVidChunks = null; +let _svVidTimer = null; +let _svVidStart = 0; +let _svVidPaused = false; +let _svVidBytes = 0; +let _svVidMimeType = ''; +let _svVidExt = ''; + +// Helpers +const $ = id => document.getElementById(id); +const _dialogReturnFocus = new WeakMap(); + +function dialogFocusable(container) { + if (!container) return []; + return [...container.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])')] + .filter(element => !element.hidden && element.getAttribute('aria-hidden') !== 'true' && element.getClientRects().length); +} + +function openAccessibleDialog(overlay, initialFocus = null) { + if (!overlay) return; + _dialogReturnFocus.set(overlay, document.activeElement instanceof HTMLElement ? document.activeElement : null); + overlay.hidden = false; + overlay.setAttribute('aria-hidden', 'false'); + document.body.classList.add('chimera-dialog-open'); + requestAnimationFrame(() => (initialFocus || dialogFocusable(overlay)[0] || overlay.querySelector('[role="dialog"]'))?.focus?.()); +} + +function closeAccessibleDialog(overlay) { + if (!overlay) return; + overlay.hidden = true; + overlay.setAttribute('aria-hidden', 'true'); + const returnFocus = _dialogReturnFocus.get(overlay); + _dialogReturnFocus.delete(overlay); + if (![...document.querySelectorAll('[role="dialog"][aria-modal="true"]')] + .some(dialog => !dialog.closest('[hidden]') && dialog.getClientRects().length)) { + document.body.classList.remove('chimera-dialog-open'); + } + if (returnFocus?.isConnected) requestAnimationFrame(() => returnFocus.focus()); +} + +document.addEventListener('keydown', event => { + const tab = event.target.closest?.('[role="tab"]'); + if (tab && ['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) { + const tabs = [...tab.closest('[role="tablist"]')?.querySelectorAll('[role="tab"]') || []].filter(item => !item.disabled && !item.hidden); + if (tabs.length) { + event.preventDefault(); + const current = Math.max(0, tabs.indexOf(tab)); + const next = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : (current + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length; + tabs[next].focus(); + tabs[next].click(); + return; + } + } + if (event.key !== 'Tab') return; + const dialogs = [...document.querySelectorAll('[role="dialog"][aria-modal="true"]')] + .filter(dialog => !dialog.closest('[hidden]') && dialog.getClientRects().length); + const dialog = dialogs.at(-1); + if (!dialog) return; + const focusable = dialogFocusable(dialog); + if (!focusable.length) { event.preventDefault(); dialog.focus(); return; } + const first = focusable[0]; + const last = focusable.at(-1); + if (event.shiftKey && (document.activeElement === first || !dialog.contains(document.activeElement))) { event.preventDefault(); last.focus(); } + else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } +}); + +document.addEventListener('error', event => { + const image = event.target; + if (!(image instanceof HTMLImageElement) || !image.dataset.imageError) return; + const fallbackSrc = image.dataset.fallbackSrc; + if (fallbackSrc && image.dataset.fallbackAttempted !== 'true' && image.currentSrc !== fallbackSrc) { + image.dataset.fallbackAttempted = 'true'; + image.classList.add('is-fallback-artwork'); + image.src = fallbackSrc; + return; + } + image.hidden = true; + if (image.dataset.imageError === 'fallback' && image.nextElementSibling) image.nextElementSibling.hidden = false; +}, true); + +function setGameWorkspaceContext(gameId, target) { + const normalized = String(gameId || '').trim().toUpperCase(); + if (!/^(?:CUSA|PPSA)\d{5}$/.test(normalized)) return false; + sessionStorage.setItem('chimera.gameWorkspace', JSON.stringify({ gameId: normalized, target: String(target || ''), at: Date.now() })); + return true; +} + +function consumeGameWorkspaceContext(target) { + try { + const raw = sessionStorage.getItem('chimera.gameWorkspace'); + const context = raw ? JSON.parse(raw) : null; + if (!context || context.target !== target || Date.now() - Number(context.at || 0) > 5 * 60 * 1000) return null; + sessionStorage.removeItem('chimera.gameWorkspace'); + return /^(?:CUSA|PPSA)\d{5}$/.test(String(context.gameId || '')) ? context.gameId : null; + } catch (_) { + sessionStorage.removeItem('chimera.gameWorkspace'); + return null; + } +} + +function _isNarrowLayout() { + return window.matchMedia('(max-width: 900px)').matches; +} + +function setMobileNavOpen(open) { + document.body.classList.toggle('mobile-nav-open', !!open && _isNarrowLayout()); + const isOpen = document.body.classList.contains('mobile-nav-open'); + const toggle = $('mobile-nav-toggle'); + if (toggle) { + toggle.setAttribute('aria-expanded', String(isOpen)); + toggle.setAttribute('aria-label', isOpen ? 'Close navigation' : 'Open navigation'); + toggle.title = isOpen ? 'Close navigation' : 'Open navigation'; + toggle.textContent = isOpen ? '×' : '☰'; + } + const companionMore = $('chimera-companion-more'); + if (companionMore) companionMore.setAttribute('aria-expanded', String(isOpen)); + const scrim = $('sidebar-scrim'); + if (scrim) scrim.hidden = !isOpen; + const companionDock = $('chimera-companion-dock'); + if (companionDock) { + companionDock.toggleAttribute('inert', isOpen); + if (isOpen) companionDock.setAttribute('aria-hidden', 'true'); + else companionDock.removeAttribute('aria-hidden'); + } + if (isOpen) { + requestAnimationFrame(() => document.querySelector('.chimera-route-link.active, .chimera-domain-home.active')?.scrollIntoView({ block: 'nearest' })); + } +} + +function closeMobileNav() { + setMobileNavOpen(false); +} + +function applyDensity(value) { + const density = value === 'compact' ? 'compact' : 'comfortable'; + document.body.dataset.density = density; + return density; +} + +function _pageLoader(page, background = false) { + const loaders = { + dashboard: () => loadDashboard(), + games: () => loadGames(), + packages: () => loadPackages(), + backporks: () => loadBackporks(), + backups: () => loadBackups(), + database: () => loadDatabase(), + settings: () => loadSettings({ background }), + jailbreak: () => loadJailbreak(), + psnotify: () => loadPsNotify(), + xavatar: () => loadXavatar(), + 'system-view': () => loadSystemView(background ? { passive: true } : {}), + transfers: () => loadTransfers(), + media: () => loadMedia(), + cheats: () => loadCheats({ background }), + garlic: () => loadGarlic(), + 'elf-arsenal': () => (typeof loadElfArsenal === 'function' ? loadElfArsenal({ background }) : null), + 'pfs-ripper': () => (typeof loadPfsRipper === 'function' ? loadPfsRipper({ background }) : null), + savemgr: () => loadSaveMgr({ background }), + ecosystem: () => (typeof loadEcosystem === 'function' ? loadEcosystem({ background }) : null), + operations: () => (typeof loadOperations === 'function' ? loadOperations({ background }) : null), + }; + return loaders[page] || null; +} + +function _pageStaleMs(page) { + return _PAGE_STALE_MS[page] || _PAGE_STALE_MS.default; +} + +function pageIsStale(page) { + const last = _pageLoadTimestamps.get(page) || 0; + return (Date.now() - last) > _pageStaleMs(page); +} + +function ensurePageReady(page, { background = false, force = false } = {}) { + const needsActiveHydrate = !background && _pageLoadModes.get(page) === 'background'; + if (!force && !needsActiveHydrate && !pageIsStale(page)) return _pageLoadInFlight.get(page) || Promise.resolve(); + if (_pageLoadInFlight.has(page)) return _pageLoadInFlight.get(page); + const run = Promise.resolve() + .then(() => window.ChimeraModuleLoader?.ensureForPage(page)) + .then(() => { + const loader = _pageLoader(page, background); + return loader ? loader() : null; + }) + .then(() => { + _pageLoadTimestamps.set(page, Date.now()); + const priorMode = _pageLoadModes.get(page); + _pageLoadModes.set(page, background && priorMode === 'active' ? 'active' : (background ? 'background' : 'active')); + }) + .catch(err => { + if (background) { + const method = isExpectedUnavailable(err) ? 'info' : 'warn'; + console[method](`[page-load] ${page} was not preloaded: ${err.message}`); + return; + } + throw err; + }) + .finally(() => { + if (_pageLoadInFlight.get(page) === run) _pageLoadInFlight.delete(page); + }); + _pageLoadInFlight.set(page, run); + return run; +} + +function scheduleBackgroundWarm(delay = 600) { + if (_pageWarmTimer || _pageWarmRunning) return; + _pageWarmTimer = setTimeout(() => { + _pageWarmTimer = null; + startBackgroundWarm().catch(() => {}); + }, delay); +} + +async function startBackgroundWarm() { + if (_pageWarmRunning) return; + _pageWarmRunning = true; + try { + for (const page of _PAGE_WARM_ORDER) { + const active = document.querySelector('.page.active')?.id?.replace('page-', ''); + if (page === active) continue; + await ensurePageReady(page, { background: true }); + await new Promise(resolve => setTimeout(resolve, 250)); + } + } finally { + _pageWarmRunning = false; + scheduleBackgroundWarm(_PAGE_STALE_MS.default); + } +} + +function fmt(bytes) { + const n = Number(bytes); + if (!n || isNaN(n)) return '—'; + const gb = n / 1073741824; + return gb >= 1 ? `${gb.toFixed(2)} GB` : `${(n / 1048576).toFixed(1)} MB`; +} + +function yesNo(v) { + return v ? 'Yes' : 'No'; +} + +function escHtml(s) { + return String(s ?? '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); +} + +// Pinnable cards registry +const PINNABLE_CARDS = [ + { id: 'payload-update', label: 'Payload Update Checker', page: 'jailbreak', hint: 'Check sources for payload updates and push to PS5.' }, + { id: 'payload-mgr', label: 'Payload Sender', page: 'jailbreak', type: 'widget', hint: 'Quick-send a payload to PS5 via TCP.' }, + { id: 'psnotify', label: 'Send Notification', page: 'psnotify', type: 'widget', hint: 'Send a PS5 notification from the dashboard.' }, + { id: 'xavatar-convert', label: 'XAvatar Quick Send', page: 'xavatar', type: 'widget', hint: 'Convert an image and send to PS5 as .xavatar.' }, + { id: 'xavatar-library', label: 'XAvatar Library', page: 'xavatar', hint: 'Manage .xavatar files on your PS5.' }, + { id: 'transfers', label: 'Transfer Manager', page: 'transfers', hint: 'Monitor active and queued FTP transfers.' }, + { id: 'games', label: 'Games Library', page: 'games', hint: 'Browse and manage your PS5 game collection.' }, + { id: 'cheats', label: 'Cheats', page: 'cheats', hint: 'Browse and install game cheats.' }, + { id: 'media', label: 'Media', page: 'media', hint: 'Browse screenshots and video clips from PS5.' }, + { id: 'system-view', label: 'System View', page: 'system-view', hint: 'View your PS5 via capture card stream.' }, + { id: 'backups', label: 'Backups', page: 'backups', hint: 'Browse and manage game backup files.' }, + // Other page widgets + { id: 'dash-savemgr', label: 'Save Manager', page: 'savemgr', type: 'widget', hint: 'Browse and download PS5 save files.' }, +]; + +const _PIN_PAGE_LABELS = { + jailbreak: 'Payload Workspace', psnotify: 'Notifications', xavatar: 'XAvatar', + transfers: 'Transfers', games: 'Games', cheats: 'Cheats & patches', media: 'Media', + 'system-view': 'System View', backups: 'Backups', savemgr: 'Local Save Manager', + ecosystem: 'Console Status', operations: 'Operations', +}; + +function setStatus(msg, type = '') { + const el = $('sb-msg'); + el.textContent = msg; + el.style.color = type === 'error' ? 'var(--red)' : type === 'ok' ? 'var(--green)' : 'var(--yellow)'; + if (msg) setTimeout(() => { if (el.textContent === msg) el.textContent = ''; }, 5000); +} + +function titleHue(id){let h=0;for(const c of String(id||''))h=(h*31+c.charCodeAt(0))%360;return h;} +// Server messages are Dutch; translated at display time via serverError.* keys. +function friendlyMessage(text) { + const message = String(text ?? '').trim(); + if (!/^en(?:-|$)/i.test(document.documentElement.lang || 'en')) return message; + const mapped = window.i18n?.strings?.['serverError.' + message]; + if (mapped) return mapped; + return message + .replace(/niet bereikbaar/gi, 'unreachable') + .replace(/niet beschikbaar/gi, 'unavailable') + .replace(/niet geconfigureerd/gi, 'not configured') + .replace(/ontbreekt/gi, 'is missing'); +} + +function showToast(msg, type = 'info', duration = 5000) { + const icons = { ok: '✓', error: '✕', info: '●' }; + const safeType = Object.hasOwn(icons, type) ? type : 'info'; + const message = friendlyMessage(msg); + if (!message) return null; + const container = $('toast-container'); + const key = `${safeType}:${message.replace(/\s+/g, ' ').toLocaleLowerCase()}`; + const existing = [...container.querySelectorAll('.toast')].find(item => item.dataset.toastKey === key); + if (existing) { + const count = Number(existing.dataset.toastCount || 1) + 1; + existing.dataset.toastCount = String(count); + let badge = existing.querySelector('.toast-count'); + if (!badge) { + badge = document.createElement('span'); + badge.className = 'toast-count'; + existing.querySelector('.toast-body')?.after(badge); + } + badge.textContent = `×${count}`; + clearTimeout(existing._dismissTimer); + existing._dismissTimer = setTimeout(() => existing._dismiss?.(), duration); + return existing; + } + const toast = document.createElement('div'); + toast.className = `toast toast-${safeType}`; + toast.dataset.toastKey = key; + toast.dataset.toastCount = '1'; + toast.setAttribute('role', safeType === 'error' ? 'alert' : 'status'); + const closeLabel = window.t?.('notifications.close', 'Close notification') || 'Close notification'; + const icon = document.createElement('span'); + icon.className = 'toast-icon'; + icon.setAttribute('aria-hidden', 'true'); + icon.textContent = icons[safeType]; + const body = document.createElement('span'); + body.className = 'toast-body'; + body.textContent = message; + const close = document.createElement('button'); + close.className = 'toast-close'; + close.type = 'button'; + close.setAttribute('aria-label', closeLabel); + close.textContent = '×'; + toast.append(icon, body, close); + while (container.children.length >= 4) container.firstElementChild?.remove(); + container.appendChild(toast); + const dismiss = () => { + if (!toast.isConnected || toast.classList.contains('toast-out')) return; + toast.classList.add('toast-out'); + toast.addEventListener('animationend', () => toast.remove(), { once: true }); + setTimeout(() => toast.remove(), 350); + }; + toast._dismiss = dismiss; + close.addEventListener('click', dismiss); + toast._dismissTimer = setTimeout(dismiss, duration); + toast.addEventListener('mouseenter', () => clearTimeout(toast._dismissTimer)); + toast.addEventListener('mouseleave', () => { toast._dismissTimer = setTimeout(dismiss, 2000); }); + return toast; +} + +// ── Navigation ──────────────────────────────────────────────────────────────── +function navigate(page) { + document.querySelectorAll('.nav-link').forEach(a => a.classList.toggle('active', a.dataset.page === page)); + document.querySelectorAll('.page').forEach(p => p.classList.toggle('active', p.id === `page-${page}`)); + + // Embed pages need #content to be overflow:hidden so the iframe can fill available height + const isEmbed = page === 'savemgr'; + document.getElementById('content').classList.toggle('embed-page-active', isEmbed); + ensurePageReady(page, { force: pageIsStale(page) }); + scheduleBackgroundWarm(800); + closeMobileNav(); + syncPinStates(); +} + +$('mobile-nav-toggle')?.addEventListener('click', () => { + setMobileNavOpen(!document.body.classList.contains('mobile-nav-open')); +}); + +$('sidebar-scrim')?.addEventListener('click', closeMobileNav); + +window.addEventListener('resize', () => { + if (!_isNarrowLayout()) closeMobileNav(); +}); + +async function syncPinStates() { + try { + const s = await window.pork.getSettings(); + const pinned = new Set(s.pinnedCards || []); + document.querySelectorAll('.btn-pin').forEach(btn => + btn.classList.toggle('pinned', pinned.has(btn.dataset.pinId)) + ); + } catch (_) {} +} + +async function loadJailbreak() { + // Reset UI + $('payload-check-status').textContent = ''; + $('payload-sources-check-list').innerHTML = ''; + if ($('payload-show-expert')) { + $('payload-show-expert').checked = localStorage.getItem('chimera.payload.showExpert') === '1'; + } + try { await loadPayloadCatalog(); } catch (error) { + $('payload-sources-check-list').innerHTML = `

    ${escHtml(window.t('payload.catalogLoadFailed', 'Catalog could not be loaded'))}: ${escHtml(error.message)}

    `; + } + await renderLocalPayloads(); + await renderPayloadSender(); + await renderAutoloaderSnapshots(); + try { + const settings = await window.pork.getSettings(); + if ($('pldmgr-port')) $('pldmgr-port').value = settings.payloadManagerPort || 8084; + if (!settings.ftpHost) { + if ($('service-matrix-summary')) $('service-matrix-summary').textContent = window.t('services.ps5NotConfigured', 'PS5 not configured'); + if ($('service-matrix-grid')) $('service-matrix-grid').innerHTML = `

    ${escHtml(window.t('services.configureProfile', 'Configure a console profile before checking services.'))}

    `; + if ($('pldmgr-version')) $('pldmgr-version').textContent = 'offline'; + if ($('pldmgr-status')) $('pldmgr-status').textContent = window.t('services.ps5NotConfigured', 'PS5 not configured'); + return; + } + await loadPs5ServiceMatrix(); + await connectPs5PayloadManager({ quiet: true }); + } catch (_) {} +} + +function isExpectedUnavailable(error) { + if (error?.expected || ['NOT_CONFIGURED', 'UNAVAILABLE', 'UPSTREAM_UNAVAILABLE'].includes(error?.code)) return true; + return /niet (?:bereikbaar|verbonden|beschikbaar|actief|geconfigureerd|gevonden)|maak eerst verbinding|not (?:reachable|connected|available|active|configured|found|mounted)|connect (?:to )?(?:the )?PS5|offline|ECONNREFUSED|timed? out|reageerde niet op tijd|HTTP (?:403|429|5\d\d)|rate.?limit|ontbreekt/i.test(String(error?.message || '')); +} + diff --git a/renderer/modules/system-state.js b/renderer/modules/system-state.js new file mode 100644 index 0000000..d4d7b30 --- /dev/null +++ b/renderer/modules/system-state.js @@ -0,0 +1,76 @@ +// System State Controls + +let pendingSystemAction = null; +const systemStateLabels = { + shutdown: 'Shutdown', reboot: 'Reboot', restmode: 'Enter Rest Mode', eject: 'Eject Disc', +}; + +function systemStateStatus(text, error = false) { + const el = $('system-state-status'); + el.textContent = text; + el.style.color = error ? 'var(--red)' : ''; +} + +async function refreshSystemStateStatus() { + systemStateStatus('Checking service...'); + const result = await window.pork.systemStateStatus(); + systemStateStatus(result.connected ? `Service ready: ${result.response || 'port 9112'}` : (result.error || result.response || 'Service unavailable.'), !result.connected); +} + +$('btn-system-state-status').addEventListener('click', () => refreshSystemStateStatus().catch(error => systemStateStatus(error.message, true))); + +$('btn-system-state-deploy').addEventListener('click', async () => { + const button = $('btn-system-state-deploy'); + button.disabled = true; + systemStateStatus('Sending payload to loader port 9021...'); + try { + const result = await window.pork.systemStateDeploy(); + systemStateStatus(`${result.message} (${result.bytes} bytes)`); + } catch (error) { + systemStateStatus(error.message, true); + } finally { + button.disabled = false; + } +}); + +document.querySelectorAll('.system-state-action').forEach((button) => button.addEventListener('click', () => { + pendingSystemAction = button.dataset.systemAction; + const command = pendingSystemAction === 'restmode' ? 'RESTMODE' : pendingSystemAction.toUpperCase(); + $('system-state-confirm-title').textContent = `Confirm: ${systemStateLabels[pendingSystemAction]}`; + const shutdownWarning = pendingSystemAction === 'shutdown' + ? ' The upstream SystemStateManager project still marks shutdown as in testing; use it only if you accept that risk.' + : ''; + $('system-state-confirm-copy').textContent = `This sends ${command} to SystemStateManager on your PS5. Type "CONFIRM ${command}" exactly to enable the action.${shutdownWarning}`; + $('system-state-confirm-input').value = ''; + $('system-state-confirm').hidden = false; + $('system-state-confirm-input').focus(); +})); + +$('system-state-confirm-input').addEventListener('input', () => { + if (!pendingSystemAction) return; + const command = pendingSystemAction === 'restmode' ? 'RESTMODE' : pendingSystemAction.toUpperCase(); + $('btn-system-state-confirm').disabled = $('system-state-confirm-input').value !== `CONFIRM ${command}`; +}); + +$('btn-system-state-cancel').addEventListener('click', () => { + pendingSystemAction = null; + $('system-state-confirm').hidden = true; +}); + +$('btn-system-state-confirm').addEventListener('click', async () => { + if (!pendingSystemAction) return; + const action = pendingSystemAction; + const command = action === 'restmode' ? 'RESTMODE' : action.toUpperCase(); + const button = $('btn-system-state-confirm'); + button.disabled = true; + systemStateStatus(`Sending ${command}...`); + try { + const result = await window.pork.systemStateCommand({ action, confirmation: `CONFIRM ${command}` }); + systemStateStatus(`${systemStateLabels[action]} command accepted: ${result.response || 'OK'}`); + $('system-state-confirm').hidden = true; + pendingSystemAction = null; + } catch (error) { + systemStateStatus(error.message, true); + button.disabled = false; + } +}); diff --git a/renderer/modules/system-view.js b/renderer/modules/system-view.js new file mode 100644 index 0000000..3db0b41 --- /dev/null +++ b/renderer/modules/system-view.js @@ -0,0 +1,767 @@ +// ── System View ─────────────────────────────────────────────────────────────── +let _svGifWorker = null; +let _svGifJobId = 0; +let _svGifSavePath = ''; +let _svVidWriteQueue = Promise.resolve(); +const SV_GIF_MAX_WIDTH = 480; +const SV_GIF_MAX_COLORS = 128; +const SV_GIF_MAX_FPS = 8; + +function updateSvSourceUi() { + $('page-system-view')?.classList.toggle('sv-has-source', Boolean($('sv-video-device')?.value)); +} + +async function loadSystemView(options = {}) { + const passive = !!options.passive; + if (window.pork.isWebMode) { + if (!passive) await _loadSystemViewWeb(); + return; + } + $('sv-status-codec').textContent = svDetectCodec().label; + if (!passive) await enumerateSvDevices(); + let autoLoad = false; + // Restore all persisted system-view settings + try { + const s = await window.pork.getSettings(); + if (s.svResolution) $('sv-resolution').value = s.svResolution; + if (s.svGifFps) $('sv-gif-fps').value = s.svGifFps; + if (s.svVidQuality) $('sv-vid-quality').value = s.svVidQuality; + if (s.svHotkeys) _svHotkeys = { ..._svHotkeys, ...s.svHotkeys }; + autoLoad = !!s.svAutoLoad; + $('sv-auto-load').checked = autoLoad; + // Restore saved device selections only if nothing is currently selected + if (!passive) { + const videoSel = $('sv-video-device'); + const audioSel = $('sv-audio-device'); + if (!videoSel.value && s.svVideoDevice && + [...videoSel.options].some(o => o.value === s.svVideoDevice)) { + videoSel.value = s.svVideoDevice; + } + if (!audioSel.value && s.svAudioDevice && + [...audioSel.options].some(o => o.value === s.svAudioDevice)) { + audioSel.value = s.svAudioDevice; + } + } + } catch (_) {} + if (!$('sv-gif-fps').value) $('sv-gif-fps').value = '8'; + if (!$('sv-vid-quality').value) $('sv-vid-quality').value = '8000000'; + // Re-attach existing stream if still active (user navigated away and back) + if (_svStream && !_svStream.active) _svStream = null; + if (_svStream) { + // Re-attach video-only (AudioContext keeps running independently) + $('sv-video').srcObject = new MediaStream(_svStream.getVideoTracks()); + $('sv-video').muted = true; + $('sv-no-signal').classList.add('hidden'); + $('btn-sv-mute').disabled = false; + $('btn-sv-popout').disabled = false; + $('btn-sv-record').disabled = false; + $('btn-sv-vid-rec').disabled = false; + $('sv-ctx-popout').disabled = false; + updateSvMuteBtn(); + svSetLive($('sv-video-device').selectedOptions[0]?.text || ''); + } else if (!passive && autoLoad && $('sv-video-device').value) { + // Auto-start stream only when the user explicitly enabled it + await startSvStream(); + } + updateSvSourceUi(); +} + +async function enumerateSvDevices() { + let devices; + try { + if (!navigator.mediaDevices?.enumerateDevices) throw new Error('MediaDevices is unavailable in this browser.'); + // Device discovery must never trigger a camera permission prompt merely by + // opening the page. Permission is requested only when the user explicitly + // starts a selected source. + devices = await Promise.race([ + navigator.mediaDevices.enumerateDevices(), + new Promise(resolve => setTimeout(() => resolve([]), 1800)), + ]); + } catch (e) { + console.error('enumerateDevices failed:', e); + $('sv-no-signal-msg').textContent = 'Capture devices could not be listed. Check the secure browser context and permissions, then retry.'; + $('sv-no-signal').classList.remove('hidden'); + return; + } + + const prevVideo = $('sv-video-device').value; + const prevAudio = $('sv-audio-device').value; + + // Video + const videoSel = $('sv-video-device'); + while (videoSel.options.length > 1) videoSel.remove(1); + devices.filter(d => d.kind === 'videoinput').forEach(d => { + videoSel.add(new Option(d.label || `Camera ${d.deviceId.slice(0,8)}`, d.deviceId)); + }); + + // Audio + const audioSel = $('sv-audio-device'); + while (audioSel.options.length > 1) audioSel.remove(1); + devices.filter(d => d.kind === 'audioinput').forEach(d => { + audioSel.add(new Option(d.label || `Mic ${d.deviceId.slice(0,8)}`, d.deviceId)); + }); + + if (!devices.some(device => device.kind === 'videoinput')) { + $('sv-no-signal-msg').textContent = 'No capture source detected. Connect a capture device, then refresh the device list.'; + $('sv-no-signal').classList.remove('hidden'); + } + + // Restore previous selection if device still present + if (prevVideo && [...videoSel.options].some(o => o.value === prevVideo)) videoSel.value = prevVideo; + if (prevAudio && [...audioSel.options].some(o => o.value === prevAudio)) audioSel.value = prevAudio; + updateSvSourceUi(); +} + +function _svAudioStop() { + if (_svAudioCtx) { _svAudioCtx.close().catch(() => {}); _svAudioCtx = null; } + _svAudioGain = null; +} + +// ── System View — stream relay (web UI forwarding) ──────────────────────────── +let _relayWs = null; +let _relayRecorder = null; +let _relayRequested = false; +let _webViewerWs = null; // browser-side viewer WS + +const _RELAY_CODECS = [ + 'video/webm;codecs=vp8,opus', + 'video/webm;codecs=vp9,opus', + 'video/webm;codecs=vp8', + 'video/webm', +]; + +async function startSvRelay() { + if (_relayWs || !_svStream || !_svStream.active) return; + try { + const status = await window.pork.webuiStatus(); + if (!status.running) return; + + const mimeType = _RELAY_CODECS.find(m => MediaRecorder.isTypeSupported(m)) || 'video/webm'; + + _relayWs = new WebSocket(`ws://localhost:${status.port}/sv-stream?role=encoder`); + + _relayWs.onopen = () => { + _relayWs.send(JSON.stringify({ type: 'meta', mimeType })); + + _relayRecorder = new MediaRecorder(_svStream, { + mimeType, + videoBitsPerSecond: 4_000_000, + }); + _relayRecorder.ondataavailable = async (e) => { + if (e.data.size === 0 || !_relayWs || _relayWs.readyState !== WebSocket.OPEN) return; + try { + const buf = await e.data.arrayBuffer(); + if (_relayWs && _relayWs.readyState === WebSocket.OPEN) _relayWs.send(buf); + } catch (_) {} + }; + _relayRecorder.start(500); + }; + + _relayWs.onclose = () => { + if (_relayRecorder && _relayRecorder.state !== 'inactive') { + try { _relayRecorder.stop(); } catch (_) {} + } + _relayRecorder = null; + _relayWs = null; + }; + _relayWs.onerror = () => { try { _relayWs.close(); } catch (_) {} }; + } catch (e) { + console.error('[SvRelay] Failed to start:', e); + } +} + +function stopSvRelay() { + if (_relayRecorder && _relayRecorder.state !== 'inactive') { + try { _relayRecorder.stop(); } catch (_) {} + } + _relayRecorder = null; + if (_relayWs) { try { _relayWs.close(); } catch (_) {} _relayWs = null; } +} + +window.pork.on('sv:relay:request', ({ active }) => { + _relayRequested = active; + if (active && _svStream && _svStream.active) { + startSvRelay().catch(() => {}); + } else if (!active) { + stopSvRelay(); + } +}); + +// ── System View — web-mode viewer ───────────────────────────────────────────── +async function _loadSystemViewWeb() { + // In server mode the capture card belongs to the browser machine, so use + // MediaDevices there directly instead of waiting for an Electron relay. + const deviceBar = document.querySelector('.sv-device-bar'); + if (deviceBar) deviceBar.hidden = false; + ['btn-sv-refresh-devices', 'btn-sv-mute', 'btn-sv-record', 'sv-gif-fps', + 'btn-sv-vid-rec', 'sv-vid-quality', + ].forEach(id => { const el = $(id); if (el) el.hidden = false; }); + $('btn-sv-popout').hidden = true; + $('sv-status-codec').textContent = typeof MediaRecorder !== 'undefined' ? svDetectCodec().label : 'Unavailable'; + + if (!window.isSecureContext || !navigator.mediaDevices?.enumerateDevices) { + $('sv-no-signal-msg').textContent = window.t('systemView.httpsRequired.detail', 'Capture needs HTTPS or localhost, plus camera and microphone access.'); + $('sv-no-signal').classList.remove('hidden'); + svSetIdle(window.t('systemView.httpsRequired', 'HTTPS required for capture')); + return; + } + + await enumerateSvDevices(); + try { + const settings = await window.pork.getSettings(); + if (settings.svResolution) $('sv-resolution').value = settings.svResolution; + if (settings.svGifFps) $('sv-gif-fps').value = settings.svGifFps; + if (settings.svVidQuality) $('sv-vid-quality').value = settings.svVidQuality; + if (settings.svVideoDevice && [...$('sv-video-device').options].some(option => option.value === settings.svVideoDevice)) $('sv-video-device').value = settings.svVideoDevice; + if (settings.svAudioDevice && [...$('sv-audio-device').options].some(option => option.value === settings.svAudioDevice)) $('sv-audio-device').value = settings.svAudioDevice; + $('sv-auto-load').checked = settings.svAutoLoad === true; + if (settings.svAutoLoad && $('sv-video-device').value) await startSvStream(); + updateSvSourceUi(); + } catch (_) {} +} + +async function startSvStream() { + if (_svRecording) stopSvRecording(); + stopSvRelay(); + if (_svStream) { _svStream.getTracks().forEach(t => t.stop()); _svStream = null; } + _svAudioStop(); + $('sv-video').srcObject = null; + + const videoId = $('sv-video-device').value; + updateSvSourceUi(); + if (!videoId) { + $('sv-no-signal-msg').textContent = 'Select a video source above to begin'; + $('sv-no-signal').classList.remove('hidden'); + $('btn-sv-mute').disabled = true; + $('btn-sv-popout').disabled = true; + $('btn-sv-record').disabled = true; + $('btn-sv-vid-rec').disabled = true; + $('sv-ctx-popout').disabled = true; + svSetIdle('No device selected'); + return; + } + + $('sv-no-signal-msg').textContent = 'Starting stream…'; + + const audioId = $('sv-audio-device').value; + const res = $('sv-resolution').value; // "1920x1080" or "" + const [rW, rH] = res ? res.split('x').map(Number) : []; + + const _buildConstraints = (exactRes) => { + const vc = { deviceId: { exact: videoId } }; + if (rW) { + vc.width = exactRes ? { exact: rW } : { ideal: rW }; + vc.height = exactRes ? { exact: rH } : { ideal: rH }; + } + return { + video: vc, + audio: audioId ? { + deviceId: { exact: audioId }, + echoCancellation: false, + noiseSuppression: false, + autoGainControl: false, + channelCount: 2, + } : false, + }; + }; + + try { + // First try exact resolution; fall back to ideal if the device can't match + try { + _svStream = await navigator.mediaDevices.getUserMedia(_buildConstraints(true)); + } catch (exactErr) { + if (rW && exactErr.name === 'OverconstrainedError') { + console.warn(`[SysView] Exact ${rW}×${rH} not supported, falling back to ideal`); + _svStream = await navigator.mediaDevices.getUserMedia(_buildConstraints(false)); + } else { + throw exactErr; + } + } + + // Feed only video tracks to the video element. Chromium syncs audio + // to the video element's clock, which introduces ~150-300 ms of buffering. + // Routing audio separately via AudioContext (latencyHint:'interactive') + // drops that to ~20-40 ms and eliminates the choppiness. + $('sv-video').srcObject = new MediaStream(_svStream.getVideoTracks()); + $('sv-video').muted = true; // always; audio is handled by AudioContext below + + const audioTracks = _svStream.getAudioTracks(); + if (audioId && audioTracks.length) { + _svAudioCtx = new AudioContext({ latencyHint: 'interactive' }); + _svAudioGain = _svAudioCtx.createGain(); + _svAudioGain.gain.value = _svMuted ? 0 : 1; + _svAudioCtx.createMediaStreamSource(new MediaStream(audioTracks)) + .connect(_svAudioGain) + .connect(_svAudioCtx.destination); + } + + $('sv-no-signal').classList.add('hidden'); + $('btn-sv-mute').disabled = false; + $('btn-sv-popout').disabled = false; + $('btn-sv-record').disabled = false; + $('btn-sv-vid-rec').disabled = false; + $('sv-ctx-popout').disabled = false; + updateSvMuteBtn(); + svSetLive($('sv-video-device').selectedOptions[0]?.text || videoId); + if (_relayRequested) startSvRelay().catch(() => {}); + } catch (e) { + $('sv-no-signal-msg').textContent = `Error: ${e.message}`; + $('sv-no-signal').classList.remove('hidden'); + $('btn-sv-mute').disabled = true; + $('btn-sv-popout').disabled = true; + $('btn-sv-record').disabled = true; + $('btn-sv-vid-rec').disabled = true; + $('sv-ctx-popout').disabled = true; + svSetIdle(`Error: ${e.message}`); + } +} + +function updateSvMuteBtn() { + $('btn-sv-mute').textContent = _svMuted ? '🔇 Unmute' : '🔊 Mute'; +} + +// ── System View — status bar ─────────────────────────────────────────────────── +function svSetLive(label) { + $('sv-status-dot').className = 'sv-status-dot live'; + $('sv-status-text').textContent = `Live — ${label}`; +} +function svSetIdle(msg = 'No device selected') { + $('sv-status-dot').className = 'sv-status-dot'; + $('sv-status-text').textContent = msg; + $('sv-status-res').textContent = ''; +} +function svSetSaving() { + $('sv-status-dot').className = 'sv-status-dot saving'; + $('sv-status-text').textContent = 'Saving video…'; +} + +function svSetGifSaving() { + $('sv-status-dot').className = 'sv-status-dot saving'; + $('sv-status-text').textContent = 'Saving GIF…'; +} + +$('sv-video').addEventListener('loadedmetadata', () => { + const { videoWidth: w, videoHeight: h } = $('sv-video'); + if (w && h) $('sv-status-res').textContent = `${w}×${h}`; +}); + +// ── System View — codec detection ───────────────────────────────────────────── +function svDetectCodec() { + const candidates = [ + { mime: 'video/mp4;codecs="avc1.640032,mp4a.40.2"', ext: 'mp4', label: 'H264/AAC · MP4' }, + { mime: 'video/mp4;codecs="avc1.42E01E,mp4a.40.2"', ext: 'mp4', label: 'H264/AAC · MP4' }, + { mime: 'video/mp4;codecs=avc1,mp4a.40.2', ext: 'mp4', label: 'H264/AAC · MP4' }, + { mime: 'video/mp4', ext: 'mp4', label: 'MP4' }, + { mime: 'video/webm;codecs="vp9,opus"', ext: 'webm', label: 'VP9/Opus · WebM' }, + { mime: 'video/webm;codecs="vp8,opus"', ext: 'webm', label: 'VP8/Opus · WebM' }, + { mime: 'video/webm', ext: 'webm', label: 'WebM' }, + ]; + return candidates.find(c => MediaRecorder.isTypeSupported(c.mime)) + || { mime: '', ext: 'webm', label: 'WebM (default)' }; +} + +// ── System View — video recording ───────────────────────────────────────────── +function svFmtBytes(n) { + if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`; + if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`; + return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`; +} +function svFmtTime(ms) { + const s = Math.floor(ms / 1000); + return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; +} +function svUpdateVidStatus() { + const elapsed = _svVidPaused + ? _svVidStart + : Date.now() - _svVidStart; + $('sv-vid-rec-time').textContent = svFmtTime(elapsed); + $('sv-vid-rec-size').textContent = _svVidBytes > 0 ? ` · ${svFmtBytes(_svVidBytes)}` : ''; +} + +async function startSvVidRecording() { + if (!_svStream) return; + + const codec = svDetectCodec(); + _svVidMimeType = codec.mime; + _svVidExt = codec.ext; + $('sv-status-codec').textContent = codec.label; + + const bitrate = Math.max(2_500_000, Math.min(12_000_000, parseInt($('sv-vid-quality').value) || 8_000_000)); + const name = `chimera-${new Date().toISOString().slice(0,19).replace(/[T:]/g,'-')}.${_svVidExt}`; + + _svVidChunks = []; + if (!window.pork.isWebMode) { + try { + const picked = await window.pork.systemViewPickSave({ + defaultPath: name, + filters: [{ name: `${_svVidExt.toUpperCase()} Video`, extensions: [_svVidExt] }], + }); + if (picked?.canceled || !picked?.filePath) return; + _svVidWritable = picked.filePath; + _svVidWriteQueue = Promise.resolve(); + await window.pork.systemViewWriteFile({ filePath: _svVidWritable, buffer: new Uint8Array(0).buffer }); + } catch (e) { + setStatus(`Video save dialog failed: ${e.message}`, 'error'); + return; + } + } else { + _svVidWritable = name; + } + + _svVidBytes = 0; _svVidPaused = false; _svVidStart = Date.now(); + + const recOpts = { videoBitsPerSecond: bitrate, audioBitsPerSecond: 256_000 }; + if (_svVidMimeType) recOpts.mimeType = _svVidMimeType; + _svVidRecorder = new MediaRecorder(_svStream, recOpts); + + _svVidRecorder.ondataavailable = async (e) => { + if (!e.data || e.data.size === 0) return; + _svVidBytes += e.data.size; + if (window.pork.isWebMode) { + _svVidChunks.push(e.data); + } else if (_svVidWritable) { + const filePath = _svVidWritable; + _svVidWriteQueue = _svVidWriteQueue.then(async () => { + const buffer = await e.data.arrayBuffer(); + await window.pork.systemViewAppendFile({ filePath, buffer }); + }).catch(err => { + console.error('[system-view] video append failed', err); + }); + } + }; + + _svVidRecorder.onstop = async () => { + clearInterval(_svVidTimer); + svSetSaving(); + await _svVidWriteQueue.catch(() => {}); + if (window.pork.isWebMode && _svVidChunks?.length) { + const url = URL.createObjectURL(new Blob(_svVidChunks, { type: _svVidMimeType || 'video/webm' })); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = _svVidWritable || name; + anchor.click(); + setTimeout(() => URL.revokeObjectURL(url), 30000); + } + _svVidChunks = null; + _svVidWritable = null; + _svVidRecorder = null; + svSetVidIdle(); + if (_svStream) svSetLive($('sv-video-device').selectedOptions[0]?.text || ''); + }; + + _svVidRecorder.start(500); + + $('btn-sv-vid-rec').hidden = true; + $('btn-sv-vid-pause').hidden = false; + $('btn-sv-vid-stop').hidden = false; + $('sv-vid-rec-status').hidden = false; + $('sv-vid-rec-label').textContent = 'REC'; + $('sv-vid-rec-status').className = 'sv-vid-rec-status'; + $('btn-sv-record').disabled = true; // no GIF while video recording + + _svVidTimer = setInterval(svUpdateVidStatus, 500); +} + +function pauseSvVidRecording() { + if (!_svVidRecorder) return; + if (_svVidPaused) { + _svVidRecorder.resume(); + _svVidStart = Date.now() - _svVidStart; + _svVidPaused = false; + $('btn-sv-vid-pause').textContent = '⏸ Pause'; + $('sv-vid-rec-label').textContent = 'REC'; + $('sv-vid-rec-status').className = 'sv-vid-rec-status'; + } else { + _svVidRecorder.pause(); + _svVidStart = Date.now() - _svVidStart; + _svVidPaused = true; + $('btn-sv-vid-pause').textContent = '▶ Resume'; + $('sv-vid-rec-label').textContent = 'PAUSED'; + $('sv-vid-rec-status').className = 'sv-vid-rec-status paused'; + } +} + +function stopSvVidRecording() { + if (!_svVidRecorder) return; + if (_svVidPaused) { _svVidRecorder.resume(); _svVidPaused = false; } + _svVidRecorder.stop(); +} + +function svSetVidIdle() { + $('btn-sv-vid-rec').hidden = false; + $('btn-sv-vid-pause').hidden = true; + $('btn-sv-vid-stop').hidden = true; + $('sv-vid-rec-status').hidden = true; + $('btn-sv-vid-pause').textContent = '⏸ Pause'; + $('sv-vid-rec-label').textContent = 'REC'; + $('sv-vid-rec-status').className = 'sv-vid-rec-status'; + $('btn-sv-record').disabled = !_svStream; + _svVidPaused = false; +} + +// ── System View — recording helpers ─────────────────────────────────────────── + +function getSvRecCtx() { + const vid = $('sv-video'); + const srcW = vid.videoWidth || GIF_W; + const srcH = vid.videoHeight || GIF_H; + const scale = srcW > SV_GIF_MAX_WIDTH ? (SV_GIF_MAX_WIDTH / srcW) : 1; + const w = Math.max(2, Math.round(srcW * scale)); + const h = Math.max(2, Math.round(srcH * scale)); + if (!_svRecCanvas || _svRecCanvas.width !== w || _svRecCanvas.height !== h) { + _svRecCanvas = document.createElement('canvas'); + _svRecCanvas.width = w; _svRecCanvas.height = h; + _svRecCtx = _svRecCanvas.getContext('2d'); + } + return _svRecCtx; +} + +function getSvGifWorker() { + if (_svGifWorker) return _svGifWorker; + const workerSource = ` + importScripts(${JSON.stringify(new URL('gif-encoder.js', location.href).href)}); + self.onmessage = e => { + const { id, width, height, fps, maxColors, frames } = e.data || {}; + try { + const enc = new self.GifEncoder(width, height, { fps, maxColors }); + for (const frame of frames || []) enc.addFrame(new Uint8ClampedArray(frame)); + const bytes = enc.encode(); + self.postMessage({ id, ok: true, bytes }, [bytes.buffer]); + } catch (error) { + self.postMessage({ id, ok: false, error: error?.message || String(error) }); + } + }; + `; + const workerUrl = URL.createObjectURL(new Blob([workerSource], { type: 'application/javascript' })); + _svGifWorker = new Worker(workerUrl); + URL.revokeObjectURL(workerUrl); + return _svGifWorker; +} + +function encodeGifOffThread({ width, height, fps, frames, maxColors }) { + return new Promise((resolve, reject) => { + const worker = getSvGifWorker(); + const id = ++_svGifJobId; + const onMessage = (event) => { + const msg = event.data || {}; + if (msg.id !== id) return; + worker.removeEventListener('message', onMessage); + if (!msg.ok) reject(new Error(msg.error || 'GIF encode failed')); + else resolve(new Uint8Array(msg.bytes)); + }; + worker.addEventListener('message', onMessage); + worker.postMessage({ + id, + width, + height, + fps, + maxColors, + frames: frames.map(f => f.buffer), + }, frames.map(f => f.buffer)); + }); +} + +async function startSvRecording() { + if (_svRecording || !_svStream) return; + const fps = Math.min(SV_GIF_MAX_FPS, parseInt($('sv-gif-fps').value) || 10); + const defaultName = `chimera-${Date.now()}.gif`; + if (window.pork.isWebMode) { + _svGifSavePath = defaultName; + } else { + const picked = await window.pork.systemViewPickSave({ + defaultPath: defaultName, + filters: [{ name: 'GIF Image', extensions: ['gif'] }], + }); + if (picked?.canceled || !picked?.filePath) return; + _svGifSavePath = picked.filePath; + } + _svRecording = true; _svRecFrames = []; _svRecStart = Date.now(); + $('btn-sv-record').textContent = '⏹ Stop & Save'; + $('sv-rec-status').hidden = false; + const ctx = getSvRecCtx(); + const vid = $('sv-video'); + const gifW = _svRecCanvas.width, gifH = _svRecCanvas.height; + _svRecInterval = setInterval(() => { + ctx.drawImage(vid, 0, 0, gifW, gifH); + _svRecFrames.push(new Uint8ClampedArray(ctx.getImageData(0, 0, gifW, gifH).data)); + }, Math.round(1000 / fps)); + _svRecTimer = setInterval(() => { + const s = Math.floor((Date.now() - _svRecStart) / 1000); + $('sv-rec-time').textContent = `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; + }, 500); +} + +async function stopSvRecording() { + if (!_svRecording) return; + clearInterval(_svRecInterval); clearInterval(_svRecTimer); + _svRecording = false; + $('btn-sv-record').textContent = '⏺ Record GIF'; + $('sv-rec-status').hidden = true; + $('sv-rec-time').textContent = '0:00'; + if (!_svRecFrames.length) return; + const fps = Math.min(SV_GIF_MAX_FPS, parseInt($('sv-gif-fps').value) || 10); + const gifW = _svRecCanvas?.width || GIF_W; + const gifH = _svRecCanvas?.height || GIF_H; + const frames = _svRecFrames; + const filePath = _svGifSavePath; + _svRecFrames = []; + _svGifSavePath = ''; + svSetGifSaving(); + try { + const bytes = await encodeGifOffThread({ + width: gifW, + height: gifH, + fps, + frames, + maxColors: SV_GIF_MAX_COLORS, + }); + if (window.pork.isWebMode) { + const url = URL.createObjectURL(new Blob([bytes], { type: 'image/gif' })); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filePath || `chimera-${Date.now()}.gif`; + anchor.click(); + setTimeout(() => URL.revokeObjectURL(url), 30000); + } else { + await window.pork.systemViewWriteFile({ filePath, buffer: bytes.buffer }); + } + } catch (e) { + console.error('[system-view] gif save failed', e); + setStatus(`GIF save failed: ${e.message}`, 'error'); + } + if (_svStream) svSetLive($('sv-video-device').selectedOptions[0]?.text || ''); +} + +// System View — event handlers (attached once at startup) +$('sv-video-device').addEventListener('change', () => { + window.pork.setSettings({ svVideoDevice: $('sv-video-device').value }); + updateSvSourceUi(); + startSvStream(); +}); +$('sv-audio-device').addEventListener('change', () => { + window.pork.setSettings({ svAudioDevice: $('sv-audio-device').value }); + startSvStream(); +}); +$('btn-sv-refresh-devices').addEventListener('click', () => enumerateSvDevices()); + +$('sv-resolution').addEventListener('change', () => { + window.pork.setSettings({ svResolution: $('sv-resolution').value }); + if ($('sv-video-device').value) startSvStream(); +}); +$('sv-gif-fps').addEventListener('change', () => + window.pork.setSettings({ svGifFps: $('sv-gif-fps').value })); +$('sv-vid-quality').addEventListener('change', () => + window.pork.setSettings({ svVidQuality: $('sv-vid-quality').value })); + +$('sv-auto-load').addEventListener('change', () => { + window.pork.setSettings({ svAutoLoad: $('sv-auto-load').checked }); +}); + +$('btn-sv-mute').addEventListener('click', () => { + _svMuted = !_svMuted; + if (_svAudioGain) _svAudioGain.gain.value = _svMuted ? 0 : 1; + updateSvMuteBtn(); +}); + +// In-app fullscreen: CSS-based fixed overlay — requestFullscreen() is +// unreliable in Electron's frameless window and silently fails. +function svToggleFullscreen() { + const wrap = $('sv-viewer-wrap'); + const entering = !wrap.classList.contains('sv-viewer-wrap--fullscreen'); + wrap.classList.toggle('sv-viewer-wrap--fullscreen', entering); + $('btn-sv-fullscreen').title = entering ? 'Exit Fullscreen (Esc)' : 'Fullscreen (or double-click)'; + $('btn-sv-fullscreen').innerHTML = entering ? '✕' : '⛶'; +} + +$('btn-sv-fullscreen').addEventListener('click', svToggleFullscreen); + +$('sv-viewer-wrap').addEventListener('dblclick', svToggleFullscreen); + +$('btn-sv-record').addEventListener('click', () => { + (_svRecording ? stopSvRecording() : startSvRecording()).catch(err => { + setStatus(err.message, 'error'); + }); +}); + +$('btn-sv-vid-rec').addEventListener('click', startSvVidRecording); +$('btn-sv-vid-pause').addEventListener('click', pauseSvVidRecording); +$('btn-sv-vid-stop').addEventListener('click', stopSvVidRecording); + +// Right-click context menu on video area +const _svCtxMenu = $('sv-ctx-menu'); +$('sv-viewer-wrap').addEventListener('contextmenu', e => { + e.preventDefault(); + const x = Math.min(e.clientX, window.innerWidth - 164); + const y = Math.min(e.clientY, window.innerHeight - 80); + _svCtxMenu.style.left = `${x}px`; + _svCtxMenu.style.top = `${y}px`; + _svCtxMenu.hidden = false; +}); +document.addEventListener('click', () => { _svCtxMenu.hidden = true; }); +$('sv-ctx-fs').addEventListener('click', svToggleFullscreen); +$('sv-ctx-popout').addEventListener('click', () => $('btn-sv-popout').click()); + +if (navigator.mediaDevices) { + navigator.mediaDevices.addEventListener('devicechange', () => enumerateSvDevices().catch(() => {})); +} + +$('btn-sv-popout').addEventListener('click', async () => { + const videoId = $('sv-video-device').value; + const audioId = $('sv-audio-device').value; + const resolution = $('sv-resolution').value; + if (_svRecording) stopSvRecording(); + stopSvRelay(); + // Stop the main stream — the pop-out will start its own + if (_svStream) { _svStream.getTracks().forEach(t => t.stop()); _svStream = null; } + _svAudioStop(); + $('sv-video').srcObject = null; + $('sv-no-signal').classList.remove('hidden'); + $('sv-no-signal-msg').textContent = 'Stream moved to pop-out window'; + $('btn-sv-mute').disabled = true; + $('btn-sv-popout').disabled = true; + $('btn-sv-record').disabled = true; + await window.pork.openSystemViewPopout(videoId, audioId, resolution); +}); + +// When pop-out closes, auto-restart stream in main window if device still selected +window.pork.on('system-view:popout-closed', () => { + if ($('page-system-view')?.classList.contains('active') && $('sv-video-device').value) { + startSvStream(); + } +}); + +// ── System View mini-cards (Transfers / Jailbreak) ──────────────────────────── + +function svMiniGetStatus() { + const sel = $('sv-video-device'); + const opt = sel?.selectedOptions?.[0]; + return (opt && opt.value) ? `Capture device: ${opt.text}` : 'No capture device selected.'; +} + +['transfers', 'jailbreak'].forEach(pageId => { + $(`sv-mini-${pageId}-hdr`).addEventListener('click', () => { + const card = $(`sv-mini-${pageId}`); + card.classList.toggle('open'); + if (card.classList.contains('open')) { + $(`sv-mini-${pageId}-status`).textContent = svMiniGetStatus(); + } + }); + + $(`btn-sv-mini-${pageId}-nav`).addEventListener('click', () => navigate('system-view')); + + $(`btn-sv-mini-${pageId}-popout`).addEventListener('click', async () => { + const videoId = $('sv-video-device').value; + const audioId = $('sv-audio-device').value; + const resolution = $('sv-resolution').value; + try { + await window.pork.openSystemViewPopout(videoId, audioId, resolution); + } catch (e) { + setStatus(e.message, 'error'); + } + }); +}); + +document.querySelectorAll('.nav-link').forEach(a => + a.addEventListener('click', e => { e.preventDefault(); navigate(a.dataset.page); }) +); + diff --git a/renderer/modules/title-workspace.js b/renderer/modules/title-workspace.js new file mode 100644 index 0000000..ff6c01b --- /dev/null +++ b/renderer/modules/title-workspace.js @@ -0,0 +1,219 @@ +(function initTitleWorkspace(root, factory) { + const api = factory(); + if (typeof module === 'object' && module.exports) module.exports = api; + if (root) root.chimeraTitleWorkspace = api; +})(typeof window !== 'undefined' ? window : globalThis, function createTitleWorkspace() { + 'use strict'; + + const array = value => Array.isArray(value) ? value : []; + const text = value => value == null || value === '' ? null : String(value); + const first = (...values) => values.map(text).find(Boolean) || null; + const lower = value => String(value || '').trim().toLowerCase(); + const missingStates = new Set(['missing', 'unavailable', 'disconnected', 'offline', 'not_mounted']); + + function deviceState(item) { + return first( + item && item.deviceAvailabilityState, + item && item.availabilityState, + item && item.device && item.device.availabilityState, + item && item.storageDevice && item.storageDevice.availabilityState, + item && item.volume && item.volume.device && item.volume.device.availabilityState + ); + } + + function locationLabel(item, fallback) { + return first( + item && item.volume && item.volume.label, + item && item.volume && item.volume.name, + item && item.volumeLabel, + item && item.device && item.device.label, + item && item.storageDevice && item.storageDevice.label, + item && item.console && item.console.name, + item && item.consoleName, + item && item.path, + fallback + ); + } + + function isConfirmedInstall(install) { + const state = lower(first(install && install.presenceState, install && install.state, install && install.installState)); + return state === 'present' || state === 'installed' || state === 'confirmed'; + } + + function isMissing(item) { + return missingStates.has(lower(deviceState(item))); + } + + function collectActivity(title, locations) { + const entries = []; + const add = (kind, value, label) => { + if (!value) return; + const timestamp = new Date(value); + if (Number.isNaN(timestamp.getTime())) return; + entries.push({ kind, label, timestamp: timestamp.toISOString() }); + }; + add('title', title.updatedAt || title.updated_at, 'Title metadata updated'); + add('title', title.createdAt || title.created_at, 'Title first recorded'); + locations.forEach(location => { + add(location.kind, location.raw.lastSeenAt || location.raw.last_seen_at, `${location.label} last seen`); + add(location.kind, location.raw.verifiedAt || location.raw.verified_at, `${location.label} verified`); + add(location.kind, location.raw.detectedAt || location.raw.detected_at, `${location.label} detected`); + }); + array(title.media).forEach(item => add('media', item.importedAt || item.provenance?.importedAt, `Imported ${item.kind || 'media'}: ${item.originalName || item.name || 'asset'}`)); + return entries.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); + } + + function project(title = {}) { + const localCopies = array(title.localCopies); + const consoleInstalls = array(title.consoleInstalls); + const packages = array(title.packages); + const saves = array(title.saves); + const trophies = array(title.trophies); + const media = array(title.media); + const locations = [ + ...localCopies.map((raw, index) => ({ + kind: 'local-copy', + label: locationLabel(raw, `Local copy ${index + 1}`), + state: deviceState(raw) || 'unknown', + version: first(raw.version && raw.version.value, raw.version, raw.appVersion), + format: first(raw.format, raw.copyType, raw.type), + path: first(raw.path, raw.relativePath), + missing: isMissing(raw), + raw + })), + ...consoleInstalls.map((raw, index) => ({ + kind: 'console-install', + label: locationLabel(raw, `Console install ${index + 1}`), + state: first(raw.presenceState, raw.state, raw.installState, 'unknown'), + version: first(raw.version && raw.version.value, raw.version, raw.appVersion), + format: first(raw.format, raw.packageType), + path: first(raw.path, raw.installPath), + missing: isMissing(raw), + confirmed: isConfirmedInstall(raw), + raw + })) + ]; + const missingDrive = Number(title.availability && title.availability.missingDeviceCount || 0) > 0 || locations.some(location => location.missing); + const installed = consoleInstalls.some(isConfirmedInstall) || Number(title.availability && title.availability.confirmedInstallCount || 0) > 0; + const protectionState = lower(first(title.protection && title.protection.state, title.protection && title.protection.status)); + const protectedCopy = ['protected', 'healthy', 'verified', 'complete'].includes(protectionState) + || Number(title.protection && title.protection.protectedCopyCount || 0) > 0; + const badges = []; + if (installed) badges.push({ code: 'installed', tone: 'positive' }); + if (localCopies.length) badges.push({ code: 'local-copy', tone: 'info' }); + if (missingDrive) badges.push({ code: 'missing-drive', tone: 'warning' }); + if (protectedCopy) badges.push({ code: 'protected', tone: 'positive' }); + if ((installed || localCopies.length) && !protectedCopy) badges.push({ code: 'needs-protection', tone: 'danger' }); + if (!locations.length) badges.push({ code: 'metadata-only', tone: 'neutral' }); + + return Object.freeze({ + title, + badges, + locations, + localCopies, + consoleInstalls, + packages, + saves, + trophies, + media, + missingDrive, + installed, + protectedCopy, + activity: collectActivity(title, locations) + }); + } + + function escapeHtml(value) { + return String(value == null ? '' : value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function tr(key, fallback) { + if (typeof window === 'undefined' || typeof window.t !== 'function') return fallback; + const translated = window.t(key); + return translated && translated !== key ? translated : fallback; + } + + function setCount(id, count) { + const node = document.getElementById(id); + if (!node) return; + node.textContent = count ? String(count) : ''; + node.hidden = !count; + } + + function emptyState(key, fallback, action) { + return `

    ${escapeHtml(tr(key, fallback))}

    ${action || ''}
    `; + } + + function renderLocation(location) { + const kind = location.kind === 'console-install' + ? tr('library.workspace.consoleInstall', 'Console install') + : tr('library.workspace.localCopy', 'Local copy'); + const details = [location.version && `v${location.version}`, location.format, location.path].filter(Boolean); + return `
    +
    ${escapeHtml(kind)}

    ${escapeHtml(location.label)}

    + ${escapeHtml(location.missing ? tr('library.workspace.driveMissing', 'Drive unavailable') : location.state)} + ${details.length ? `

    ${details.map(escapeHtml).join(' · ')}

    ` : ''} +
    `; + } + + function renderList(id, items, emptyKey, emptyFallback, mapper, action) { + const node = document.getElementById(id); + if (!node) return; + node.innerHTML = items.length ? items.map(mapper).join('') : emptyState(emptyKey, emptyFallback, action); + } + + function render(title) { + if (typeof document === 'undefined') return project(title); + const workspace = project(title); + const labels = { + installed: tr('library.badge.installed', 'Installed'), + 'local-copy': tr('library.badge.localCopy', 'Local copy'), + 'missing-drive': tr('library.badge.missingDrive', 'Drive unavailable'), + protected: tr('library.badge.protected', 'Protected'), + 'needs-protection': tr('library.badge.needsProtection', 'Needs protection'), + 'metadata-only': tr('library.badge.metadataOnly', 'Metadata only') + }; + const badges = document.getElementById('modal-derived-badges'); + if (badges) badges.innerHTML = workspace.badges.map(badge => `${escapeHtml(labels[badge.code])}`).join(''); + + renderList('modal-copies-content', workspace.locations, 'library.workspace.noCopies', 'No local copies or confirmed console installations recorded.', renderLocation); + renderList('modal-packages-content', workspace.packages, 'library.workspace.noPackages', 'No package evidence linked to this title.', item => `
    ${escapeHtml(first(item.name, item.label, item.packageId, 'Package'))}${escapeHtml(first(item.state, item.status, item.type, 'recorded'))}
    `); + window.chimeraRecipeEditor?.mount?.({ title, workspace }); + renderList('modal-saves-content', workspace.saves, 'library.workspace.noSaves', 'No save evidence linked to this title.', item => `
    ${escapeHtml(first(item.name, item.accountLabel, 'Save'))}${escapeHtml(first(item.state, item.status, 'recorded'))}
    `, ``); + renderList('modal-trophies-content', workspace.trophies, 'library.workspace.noTrophies', 'No verified read-only trophy evidence imported.', item => `
    ${escapeHtml(first(item.name, item.label, 'Trophy evidence'))}${escapeHtml(first(item.state, item.status, 'read-only'))}
    `); + renderList('modal-media-content', workspace.media, 'library.workspace.noMedia', 'No media assets linked to this title.', item => `
    ${escapeHtml(first(item.originalName, item.name, item.label, item.kind, 'Media'))}${escapeHtml(first(item.provenance?.provider, item.state, item.status, 'available'))}${item.preferred ? ` · ${escapeHtml(tr('media.import.preferredActive', 'Preferred'))}` : ''}
    `, ``); + renderList('modal-activity-content', workspace.activity, 'library.workspace.noActivity', 'No timestamped evidence recorded yet.', item => `
    ${escapeHtml(item.label)}
    `); + + const compatibility = document.getElementById('modal-compatibility-content'); + if (compatibility) { + const versions = workspace.locations.map(item => item.version).filter(Boolean); + const formats = workspace.locations.map(item => item.format).filter(Boolean); + compatibility.innerHTML = `
    +
    ${escapeHtml(tr('library.workspace.availability', 'Availability'))}
    ${escapeHtml(workspace.missingDrive ? tr('library.workspace.partialAvailability', 'Partial - drive unavailable') : workspace.locations.length ? tr('library.workspace.availableEvidence', 'Evidence available') : tr('library.workspace.unknown', 'Unknown'))}
    +
    ${escapeHtml(tr('library.workspace.versions', 'Observed versions'))}
    ${escapeHtml([...new Set(versions)].join(', ') || tr('library.workspace.unknown', 'Unknown'))}
    +
    ${escapeHtml(tr('library.workspace.formats', 'Observed formats'))}
    ${escapeHtml([...new Set(formats)].join(', ') || tr('library.workspace.unknown', 'Unknown'))}
    +
    ${escapeHtml(tr('library.workspace.protection', 'Protection'))}
    ${escapeHtml(workspace.protectedCopy ? tr('library.workspace.protected', 'Protected copy recorded') : tr('library.workspace.unprotected', 'No protected copy recorded'))}
    +
    `; + window.chimeraCompatibilityGuidance?.load?.(compatibility, { + titleId: first(title.externalTitleId, title.game_id, title.gameId), + version: versions[0] || '', firmware: first(title.consoleFirmware, title.firmware), + loader: first(title.loader, title.hen), format: formats[0] || '' + }); + } + + setCount('mtab-copies-count', workspace.locations.length); + setCount('mtab-packages-count', workspace.packages.length); + setCount('mtab-saves-count', workspace.saves.length); + setCount('mtab-trophies-count', workspace.trophies.length); + setCount('mtab-media-count', workspace.media.length); + setCount('mtab-activity-count', workspace.activity.length); + return workspace; + } + + return Object.freeze({ project, render }); +}); diff --git a/renderer/modules/transfers.js b/renderer/modules/transfers.js new file mode 100644 index 0000000..6621d3c --- /dev/null +++ b/renderer/modules/transfers.js @@ -0,0 +1,216 @@ +// ── Payload Manager Settings modal ──────────────────────────────────────────── + +$('btn-payload-settings').addEventListener('click', async () => { + const s = await window.pork.getSettings(); + $('pm-local-path').value = s.payloadLocalPath || ''; + $('pm-remote-path').value = s.payloadRemotePath || ''; + openAccessibleDialog($('payload-settings-overlay'), $('pm-remote-path')); +}); + +$('btn-pm-browse-local').addEventListener('click', async () => { + const folder = await window.pork.selectFolder(); + if (folder) $('pm-local-path').value = folder; +}); + +$('btn-pm-settings-save').addEventListener('click', async () => { + await window.pork.setSettings({ + payloadLocalPath: $('pm-local-path').value.trim(), + payloadRemotePath: $('pm-remote-path').value.trim(), + }); + closeAccessibleDialog($('payload-settings-overlay')); +}); + +['payload-settings-close', 'btn-pm-settings-cancel'].forEach(id => + $(id).addEventListener('click', () => closeAccessibleDialog($('payload-settings-overlay'))) +); + +$('payload-settings-overlay').addEventListener('click', event => { + if (event.target === $('payload-settings-overlay')) closeAccessibleDialog($('payload-settings-overlay')); +}); +$('payload-settings-overlay').addEventListener('keydown', event => { + if (event.key === 'Escape') closeAccessibleDialog($('payload-settings-overlay')); +}); + +// ── Transfer Manager ────────────────────────────────────────────────────────── + +function fmtSpeed(bps) { + if (!bps || bps < 0) return '—'; + if (bps < 1024) return `${Math.round(bps)} B/s`; + if (bps < 1048576) return `${(bps / 1024).toFixed(1)} KB/s`; + return `${(bps / 1048576).toFixed(1)} MB/s`; +} + +let _transferState = { jobs: [], paused: false, maxConcurrent: 1, activeCount: 0, queuedCount: 0, interruptedCount: 0, policy: {} }; + +function renderTransferPage(st) { + _transferState = st; + + // Stats bar + $('tstat-active').textContent = st.activeCount; + $('tstat-queued').textContent = st.queuedCount; + $('tstat-interrupted').textContent = st.interruptedCount || 0; + $('tstat-concurrent').textContent = st.maxConcurrent; + + const slider = $('transfer-concurrent-slider'); + if (slider && parseInt(slider.value) !== st.maxConcurrent) slider.value = st.maxConcurrent; + if ($('transfer-window-start') && document.activeElement !== $('transfer-window-start')) $('transfer-window-start').value = st.policy?.windowStart || ''; + if ($('transfer-window-end') && document.activeElement !== $('transfer-window-end')) $('transfer-window-end').value = st.policy?.windowEnd || ''; + if ($('transfer-policy-state')) $('transfer-policy-state').textContent = st.policy?.windowStart && st.policy?.windowEnd + ? `${st.policy.windowStart}–${st.policy.windowEnd} · ${window.t('transfers.policy.bandwidthUnsupported', 'bandwidth limiting is not supported by the current FTP layer')}` + : `${window.t('transfers.policy.noWindow', 'No time window')} · ${window.t('transfers.policy.bandwidthUnsupported', 'bandwidth limiting is not supported by the current FTP layer')}`; + + // Total speed from all active jobs + const totalSpeed = st.jobs + .filter(j => j.status === 'active') + .reduce((sum, j) => sum + (j.progress?.speedBps || 0), 0); + $('tstat-speed').textContent = fmtSpeed(totalSpeed); + + // Pause/resume button + const pauseBtn = $('btn-transfer-pause'); + if (pauseBtn) { + pauseBtn.textContent = st.paused + ? `▶ ${window.t('transfers.resumeAll', 'Resume All')}` + : `⏸ ${window.t('transfers.pauseAll', 'Pause All')}`; + pauseBtn.disabled = !st.paused && !st.jobs.some(job => ['queued', 'active', 'retrying'].includes(job.status)); + } + const clearBtn = $('btn-transfer-clear'); + if (clearBtn) clearBtn.disabled = !st.jobs.some(job => ['done', 'error', 'cancelled'].includes(job.status)); + + // Job list + const list = $('transfers-list'); + const empty = $('transfers-empty'); + const visibleJobs = st.jobs.filter(j => j.status !== 'cancelled'); + + if (!visibleJobs.length) { + list.innerHTML = ''; + empty.hidden = false; + return; + } + empty.hidden = true; + + list.innerHTML = visibleJobs.map(job => { + const statusText = { + queued: 'Queued', + active: 'Transferring…', + done: 'Done', + error: 'Error', + cancelled: 'Cancelled', + interrupted: window.t('transfers.status.interrupted', 'Interrupted after restart'), + retrying: 'Retrying…', + }[job.status] || job.status; + + const typeLabel = { upload: 'UPLOAD', download: 'DOWNLOAD', pork: 'PORK' }[job.type] || (job.type || '').toUpperCase(); + const typeClass = { upload: 'type-upload', download: 'type-download', pork: 'type-pork' }[job.type] || ''; + + const pct = job.progress?.percent || 0; + const speed = job.status === 'active' ? fmtSpeed(job.progress?.speedBps) : ''; + const file = job.progress?.file || ''; + const filesDone = job.progress?.filesDone || 0; + const filesTotal = job.progress?.filesTotal || 0; + const transferred = job.progress?.transferred || 0; + const total = job.progress?.total || 0; + + const showCancel = job.cancellable !== false && ['queued', 'active', 'retrying'].includes(job.status); + const showRetry = ['error', 'interrupted'].includes(job.status) && job.retryable; + const showBar = job.status === 'active' || job.status === 'done'; + + const currentBytes = job.progress?.currentBytes || 0; + const currentFileSize = job.progress?.currentFileSize || 0; + + // File counter or single-file byte progress + const sizeStr = filesTotal > 1 + ? `File ${filesDone}/${filesTotal}` + : (total > 0 ? `${fmt(transferred)} / ${fmt(total)}` : ''); + + // Current-file byte progress for directory uploads (shows activity on large files) + const fileByteStr = (filesTotal > 1 && currentFileSize > 0 && job.status === 'active') + ? `${fmt(currentBytes)} / ${fmt(currentFileSize)}` + : ''; + + const pctLabel = (showBar && job.status === 'active') ? `${pct}%` : (job.status === 'done' ? '100%' : ''); + + return ` +
    +
    + ${escHtml(job.label)} + ${typeLabel} + ${statusText} + ${showRetry ? `` : ''} + ${showCancel ? `` : ''} +
    + ${showBar ? ` +
    +
    +
    +
    + ${pctLabel ? `${pctLabel}` : ''} +
    ` : ''} +
    + ${speed ? `${speed}` : ''} + ${file ? `${escHtml(file)}` : ''} + ${fileByteStr ? `${fileByteStr}` : sizeStr ? `${sizeStr}` : ''} + ${job.error ? `${escHtml(job.error)}` : ''} + ${job.cancellable === false && ['queued', 'active', 'retrying'].includes(job.status) ? 'Upstream offers no safe cancellation; only task status is tracked.' : ''} +
    +
    `; + }).join(''); +} + +function updateNavTransferBadge(st) { + const badge = $('nav-transfer-badge'); + if (!badge) return; + const n = (st.activeCount || 0) + (st.queuedCount || 0); + badge.hidden = n === 0; + badge.textContent = n; +} + +async function loadTransfers() { + const st = await window.pork.transferState(); + renderTransferPage(st); + updateNavTransferBadge(st); +} + +$('btn-transfer-pause').addEventListener('click', async () => { + if (_transferState.paused) await window.pork.transferResume(); + else await window.pork.transferPause(); +}); + +$('btn-transfer-clear').addEventListener('click', () => window.pork.transferClearDone()); + +$('transfer-concurrent-slider').addEventListener('input', async event => { + const n = parseInt(event.target.value); + $('tstat-concurrent').textContent = n; + await window.pork.transferSetConcurrent(n); +}); + +$('transfers-list').addEventListener('click', event => { + const retry = event.target.closest('.btn-retry-transfer'); + if (retry) { + window.pork.transferRetry?.(retry.dataset.jobId).catch(error => showToast(error.message, 'error')); + return; + } + const cancel = event.target.closest('.btn-cancel-transfer'); + if (cancel?.dataset.jobId) window.pork.transferCancel(cancel.dataset.jobId); +}); + +$('btn-transfer-policy-save').addEventListener('click', async () => { + try { + await window.pork.transferSetPolicy?.({ + windowStart: $('transfer-window-start').value || null, + windowEnd: $('transfer-window-end').value || null, + }); + showToast(window.t('transfers.policy.saved', 'Transfer policy saved.'), 'ok'); + } catch (error) { + showToast(error.message, 'error'); + } +}); + +window.pork.on('transfer:update', state => { + renderTransferPage(state); + updateNavTransferBadge(state); +}); + +window.addEventListener('chimera:language-changed', () => { + if (document.querySelector('#page-transfers.active')) renderTransferPage(_transferState); +}); + diff --git a/renderer/modules/xavatar.js b/renderer/modules/xavatar.js new file mode 100644 index 0000000..b1bd461 --- /dev/null +++ b/renderer/modules/xavatar.js @@ -0,0 +1,707 @@ +// ── PS Avatar ───────────────────────────────────────────────────────────────── + +let _xavatarClient = null; +let _xavatarLastResult = null; // { buffer: ArrayBuffer, filename: string } +const _xavatarThumbCache = new Map(); // remotePath → blob URL +let _ps5ProfileInventory = null; +let _ps5ProfileSelectedUid = null; +let _ps5ProfileBound = false; + +function ps5ProfileText(key, fallback) { + return typeof window.t === 'function' ? window.t(`ps5Profile.${key}`, fallback) : fallback; +} + +function ps5ProfileSetAction(message, state = '') { + const target = $('ps5-profile-action-state'); + if (!target) return; + target.textContent = message || ''; + target.className = `ps5-profile-action-state${state ? ` is-${state}` : ''}`; +} + +function ps5ProfileSelectedUser() { + return _ps5ProfileInventory?.users?.find(user => user.uid === _ps5ProfileSelectedUid) || null; +} + +async function loadPs5ProfileRuntime() { + const card = document.querySelector('.ps5-profile-runtime'); + const title = $('ps5-profile-runtime-title'); + const state = $('ps5-profile-runtime-state'); + const detail = $('ps5-profile-runtime-detail'); + const button = $('btn-ps5-profile-start-payload'); + if (!card || !state || !detail || !button || !window.pork?.ps5ProfileRuntimePreflight) return null; + card.classList.remove('is-ready'); + button.disabled = true; + state.textContent = ps5ProfileText('runtimeCheckingShort', 'Checking…'); + try { + const result = await window.pork.ps5ProfileRuntimePreflight(); + if (title) title.textContent = `PS5Upload ${result.version || '5.4.19'}`; + const artifactsReady = result.engine?.verified && result.engine?.available && result.payload?.verified && result.console?.configured; + if (result.ready) { + card.classList.add('is-ready'); + state.textContent = ps5ProfileText('runtimeReady', 'Ready to start'); + detail.textContent = ps5ProfileText('runtimeReadyDetail', 'Exact engine and ELF hashes verified; the PS5 loader is reachable on port 9021.'); + button.disabled = false; + } else if (artifactsReady && result.loader?.reason === 'unreachable') { + state.textContent = ps5ProfileText('runtimeLoaderClosed', 'Loader waiting'); + detail.textContent = ps5ProfileText('runtimeLoaderHelp', 'Engine and ELF are verified. Start the jailbreak ELF loader on the PS5, then refresh this check.'); + } else { + state.textContent = ps5ProfileText('runtimeBlocked', 'Preflight blocked'); + detail.textContent = ps5ProfileText('runtimeBlockedDetail', 'A pinned artifact, configured PS5 host or local engine check failed.'); + } + return result; + } catch (error) { + state.textContent = ps5ProfileText('runtimeBlocked', 'Preflight blocked'); + detail.textContent = error.message; + return null; + } +} + +function renderPs5ProfileOperations(operations = []) { + const target = $('ps5-profile-operations'); + if (!target) return; + if (!operations.length) { + target.innerHTML = `
    ${escHtml(ps5ProfileText('noActivity', 'No prepared profile changes yet.'))}
    `; + return; + } + target.innerHTML = operations.slice().reverse().slice(0, 8).map(operation => { + const canRollback = ['verified', 'verification_failed', 'failed', 'interrupted'].includes(operation.status); + const label = operation.kind === 'rename' ? ps5ProfileText('renameActivity', 'Display name') : ps5ProfileText('avatarActivity', 'Profile image'); + const detail = operation.kind === 'rename' ? `${operation.snapshot?.username || ''} → ${operation.desired?.username || ''}` : operation.uidHex; + return `
    +
    ${escHtml(label)} ${escHtml(operation.status)}${escHtml(detail)} · ${escHtml(new Date(operation.updatedAt || operation.createdAt).toLocaleString())}
    + ${canRollback ? `` : ''} +
    `; + }).join(''); +} + +function selectPs5LocalProfile(uid) { + _ps5ProfileSelectedUid = Number(uid); + const user = ps5ProfileSelectedUser(); + document.querySelectorAll('.ps5-profile-user').forEach(card => card.classList.toggle('is-selected', Number(card.dataset.profileUid) === _ps5ProfileSelectedUid)); + const name = $('ps5-profile-name'); + const rename = $('btn-ps5-profile-rename'); + const applyAvatar = $('btn-ps5-profile-apply-avatar'); + if (!user) return; + $('ps5-profile-selected-title').textContent = user.username; + $('ps5-profile-selected-uid').textContent = user.uidHex; + $('ps5-profile-avatar-initial').textContent = [...user.username][0]?.toUpperCase() || '?'; + name.value = user.username; + name.disabled = false; + rename.disabled = false; + applyAvatar.disabled = !_xavatarClient?._masterCanvas; + ps5ProfileSetAction(user.avatar?.available + ? ps5ProfileText('avatarObserved', 'Current profile image evidence was read successfully.') + : ps5ProfileText('avatarDefault', 'This user currently has no recoverable custom image. Applying is blocked until exact recovery is possible.')); + const preview = $('ps5-profile-avatar-preview'); + const shell = preview?.closest('.ps5-profile-avatar-shell'); + if (preview && shell && !_xavatarClient?._masterCanvas) { + shell.classList.remove('has-preview'); + window.pork.ps5ProfileCurrentAvatar({ uid: user.uid }).then(result => { + if (_ps5ProfileSelectedUid !== user.uid || !result?.dataUrl || _xavatarClient?._masterCanvas) return; + const image = new Image(); + image.onload = () => { const context = preview.getContext('2d'); context.clearRect(0, 0, preview.width, preview.height); context.drawImage(image, 0, 0, preview.width, preview.height); shell.classList.add('has-preview'); }; + image.src = result.dataUrl; + }).catch(() => {}); + } +} + +async function loadPs5LocalProfiles() { + const provider = $('ps5-profile-provider-state'); + const usersTarget = $('ps5-profile-users'); + if (!provider || !usersTarget || !window.pork?.ps5ProfileInspect) return; + if (!_ps5ProfileBound) bindPs5ProfileWorkspace(); + loadPs5ProfileRuntime(); + provider.className = 'ps5-profile-provider-state'; + provider.textContent = ps5ProfileText('checking', 'Checking the guarded profile bridge…'); + usersTarget.innerHTML = `
    ${escHtml(ps5ProfileText('loading', 'Reading local users…'))}
    `; + try { + const [inventory, operations] = await Promise.all([window.pork.ps5ProfileInspect(), window.pork.ps5ProfileListOperations()]); + _ps5ProfileInventory = inventory; + renderPs5ProfileOperations(operations); + if (!inventory.available) { + provider.classList.add('is-blocked'); + provider.textContent = ps5ProfileText('bridgeBlocked', 'Profile bridge unavailable · configure PS5 host and pinned PS5Upload 5.4.19'); + usersTarget.innerHTML = `
    ${escHtml(ps5ProfileText('bridgeHelp', 'Start the pinned loopback bridge and configure the PS5 host. No console change is possible while this gate is closed.'))}
    `; + return; + } + provider.classList.add('is-ready'); + provider.textContent = `${ps5ProfileText('bridgeReady', 'Verified bridge')} · ${inventory.provider?.version || '5.4.19'}`; + usersTarget.innerHTML = inventory.users.length ? inventory.users.map(user => ``).join('') : `
    ${escHtml(ps5ProfileText('noUsers', 'No local users were reported.'))}
    `; + const preferred = inventory.users.find(user => user.uid === _ps5ProfileSelectedUid) || inventory.users.find(user => user.foreground) || inventory.users[0]; + if (preferred) selectPs5LocalProfile(preferred.uid); + } catch (error) { + provider.classList.add('is-blocked'); + provider.textContent = ps5ProfileText('bridgeError', 'Profile bridge check failed'); + usersTarget.innerHTML = `
    ${escHtml(error.message)}
    `; + } +} + +function bindPs5ProfileWorkspace() { + if (_ps5ProfileBound) return; + _ps5ProfileBound = true; + $('btn-ps5-profile-refresh')?.addEventListener('click', loadPs5LocalProfiles); + $('btn-ps5-profile-start-payload')?.addEventListener('click', async () => { + const button = $('btn-ps5-profile-start-payload'); + const confirmation = await showExactConfirmation( + ps5ProfileText('startPayloadConfirm', 'Send only the verified PS5Upload 5.4.19 ELF to the configured PS5 loader? This payload remains active until rest mode or reboot.'), + 'START PS5 PROFILE PAYLOAD', + ); + if (!confirmation) return; + button.disabled = true; + ps5ProfileSetAction(ps5ProfileText('startingPayload', 'Sending the verified payload and checking the management service…')); + try { + const result = await window.pork.ps5ProfileStartPayload({ confirmation }); + if (!result.managementReady) throw new Error(ps5ProfileText('payloadNotReady', 'The ELF was sent, but the PS5 management service did not become ready. Check the jailbreak and firmware compatibility.')); + ps5ProfileSetAction(ps5ProfileText('payloadReady', 'Verified payload active. Reading local profiles…'), 'ok'); + await loadPs5LocalProfiles(); + } catch (error) { + ps5ProfileSetAction(error.message, 'error'); + await loadPs5ProfileRuntime(); + } + }); + $('ps5-profile-users')?.addEventListener('click', event => { + const card = event.target.closest('.ps5-profile-user'); + if (card) selectPs5LocalProfile(card.dataset.profileUid); + }); + $('btn-ps5-profile-rename')?.addEventListener('click', async () => { + const user = ps5ProfileSelectedUser(); + if (!user) return; + ps5ProfileSetAction(ps5ProfileText('preparing', 'Creating recovery snapshot and checking fresh evidence…')); + try { + const operation = await window.pork.ps5ProfilePrepareRename({ uid: user.uid, name: $('ps5-profile-name').value }); + const confirmation = await showExactConfirmation(ps5ProfileText('renameConfirm', 'Apply this local display name after the recovery snapshot and fresh-evidence check?'), operation.confirmation); + if (!confirmation) return void loadPs5LocalProfiles(); + await window.pork.ps5ProfileApply({ id: operation.id, confirmation }); + ps5ProfileSetAction(ps5ProfileText('verifiedRestart', 'Verified on the console. Restart the PS5 to show the change everywhere.'), 'ok'); + await loadPs5LocalProfiles(); + } catch (error) { ps5ProfileSetAction(error.message, 'error'); } + }); + $('btn-ps5-profile-apply-avatar')?.addEventListener('click', async () => { + const user = ps5ProfileSelectedUser(); + if (!user || !_xavatarClient?._masterCanvas) return; + ps5ProfileSetAction(ps5ProfileText('converting', 'Building the canonical profile image set…')); + try { + const converted = await _xavatarClient.convert(); + const operation = await window.pork.ps5ProfilePrepareAvatar({ uid: user.uid, archive: converted.buffer }); + const confirmation = await showExactConfirmation(ps5ProfileText('avatarConfirm', 'Apply this reviewed image after preserving the current profile image?'), operation.confirmation); + if (!confirmation) return void loadPs5LocalProfiles(); + await window.pork.ps5ProfileApply({ id: operation.id, confirmation }); + ps5ProfileSetAction(ps5ProfileText('verifiedRestart', 'Verified on the console. Restart the PS5 to show the change everywhere.'), 'ok'); + await loadPs5LocalProfiles(); + } catch (error) { ps5ProfileSetAction(error.message, 'error'); } + }); + $('ps5-profile-operations')?.addEventListener('click', async event => { + const button = event.target.closest('.ps5-profile-rollback'); + const row = button?.closest('.ps5-profile-operation'); + if (!row) return; + const phrase = `ROLLBACK PS5 PROFILE ${row.dataset.profileUid}`; + const confirmation = await showExactConfirmation(ps5ProfileText('rollbackConfirm', 'Restore the exact local profile state captured before this operation?'), phrase); + if (!confirmation) return; + button.disabled = true; + try { + await window.pork.ps5ProfileRollback({ id: row.dataset.profileOperation, confirmation }); + ps5ProfileSetAction(ps5ProfileText('rollbackVerified', 'Previous local profile state restored and verified.'), 'ok'); + await loadPs5LocalProfiles(); + } catch (error) { ps5ProfileSetAction(error.message, 'error'); button.disabled = false; } + }); +} + +async function loadXavatarCompanion() { + const status = $('xavatar-companion-status'); + const button = $('btn-xavatar-install-companion'); + if (!status || !button || !window.pork.xavatarCompanion) return; + try { + const companion = await window.pork.xavatarCompanion(); + button.disabled = !companion.installerReady; + button.dataset.ready = companion.installerReady ? 'true' : 'false'; + status.textContent = companion.installerReady + ? `${companion.titleId} · v${companion.version} · ${(companion.size / 1024 / 1024).toFixed(1)} MB · etaHEN DPI ready` + : `${companion.titleId} · v${companion.version} · ${companion.installerError}`; + status.title = `Expected SHA-256: ${companion.sha256}`; + } catch (error) { + button.disabled = true; + status.textContent = `Installer check failed: ${error.message}`; + } + if (button.dataset.bound) return; + button.dataset.bound = 'true'; + button.addEventListener('click', async () => { + const confirmation = await showExactConfirmation( + "Install Lapy's Avatar Changer through etaHEN Direct Package Installer? The PS5 downloads the fixed reviewed package URL; Chimera does not launch the app.", + 'INSTALL XAVATAR', + ); + if (!confirmation) return; + button.disabled = true; + status.textContent = 'Submitting package to etaHEN DPI…'; + try { + await window.pork.xavatarInstallCompanion(confirmation); + status.textContent = 'Installation submitted to etaHEN DPI. Follow the package progress on your PS5.'; + showToast('Avatar Changer installation submitted.', 'ok', 9000); + } catch (error) { + status.textContent = `Installation failed: ${error.message}`; + showToast(error.message, 'error', 10000); + } + await loadXavatarCompanion(); + }); +} + +function loadXavatar() { + // Refresh the library on every navigate; only initialise the converter once. + loadXavatarLibrary(); + loadXavatarCompanion(); + loadPs5LocalProfiles(); + + if (_xavatarClient) return; + + if (!window.XavatarClient) { + $('xavatar-status').textContent = 'XavatarClient not available — ensure the module script loaded.'; + return; + } + + // Build the API object — prefer the dedicated xavatarAPI bridge; fall back to + // the pork bridge channel so conversion still works even if the separate + // xavatarElectronModule preload failed to expose window.xavatarAPI. + let _xavApi = window.xavatarAPI; + if (!_xavApi) { + console.info('[Chimera] Dedicated XAvatar bridge unavailable; using the supported web compatibility path.'); + _xavApi = { + async convertFromCanvas(canvas, opts = {}) { + const ctx = canvas.getContext('2d'); + const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const rgba = new Uint8Array(imgData.data.buffer); + const res = await window.pork.xavatarConvertCanvas( + rgba, canvas.width, canvas.height, opts.filename || 'avatar' + ); + if (!res || !res.ok) throw new Error((res && res.error) || 'Conversion failed'); + return { buffer: res.buffer && res.buffer.buffer ? res.buffer.buffer : res.buffer, filename: res.filename }; + } + }; + } + + _xavatarClient = new window.XavatarClient({ api: _xavApi }); + + const dropZone = $('xavatar-drop-zone'); + const fileInput = $('xavatar-file-input'); + const preview = $('xavatar-preview'); + const status = $('xavatar-status'); + const convertBtn = $('btn-xavatar-convert'); + const sendBtn = $('btn-xavatar-send-ps5'); + + const convProgress = $('xavatar-conv-progress'); + const convBar = $('xavatar-conv-bar'); + + function _xavatarSetProgress(pct, label) { + if (!convProgress) return; + convProgress.hidden = pct == null; + if (pct != null) { + convBar.style.width = `${pct}%`; + convBar.textContent = label || ''; + } + } + + _xavatarClient.on('image-ready', (canvas) => { + const ctx = preview.getContext('2d'); + preview.width = canvas.width; + preview.height = canvas.height; + ctx.drawImage(canvas, 0, 0); + preview.hidden = false; + convertBtn.disabled = false; + sendBtn.disabled = false; + _xavatarLastResult = null; // new image — invalidate previous conversion + _xavatarSetProgress(null); + status.textContent = 'Image loaded — convert & download, or send directly to PS5'; + dropZone.classList.add('xavatar-drop-zone--has-image'); + const profilePreview = $('ps5-profile-avatar-preview'); + const profileShell = profilePreview?.closest('.ps5-profile-avatar-shell'); + if (profilePreview && profileShell) { + const profileContext = profilePreview.getContext('2d'); + profileContext.clearRect(0, 0, profilePreview.width, profilePreview.height); + profileContext.drawImage(canvas, 0, 0, profilePreview.width, profilePreview.height); + profileShell.classList.add('has-preview'); + $('btn-ps5-profile-apply-avatar').disabled = !ps5ProfileSelectedUser(); + } + }); + + _xavatarClient.on('load-error', (err) => { + status.textContent = 'Failed to load image: ' + err.message; + }); + + _xavatarClient.on('convert-start', () => { + convertBtn.disabled = true; + convertBtn.textContent = 'Converting…'; + status.textContent = 'Converting…'; + }); + + _xavatarClient.on('convert-error', (err) => { + convertBtn.disabled = false; + convertBtn.textContent = 'Convert & Download'; + _xavatarSetProgress(null); + status.textContent = 'Error: ' + err.message; + }); + + _xavatarClient.wireFileInput(fileInput); + _xavatarClient.wireDropZone(dropZone); + _xavatarClient.wirePaste(document); + + $('btn-xavatar-browse').addEventListener('click', () => fileInput.click()); + dropZone.addEventListener('click', (e) => { + if (e.target !== $('btn-xavatar-browse')) fileInput.click(); + }); + + dropZone.addEventListener('dragover', () => dropZone.classList.add('xavatar-drop-zone--over')); + dropZone.addEventListener('dragleave', () => dropZone.classList.remove('xavatar-drop-zone--over')); + dropZone.addEventListener('drop', () => dropZone.classList.remove('xavatar-drop-zone--over')); + + convertBtn.addEventListener('click', async () => { + status.textContent = ''; + _xavatarSetProgress(null); + try { + const result = await _xavatarClient.convert(); + _xavatarLastResult = result; + convertBtn.disabled = false; + convertBtn.textContent = 'Convert & Download'; + const saved = await window.pork.xavatarSaveFile(result.buffer, result.filename); + status.textContent = saved?.saved ? `Saved: ${result.filename}` : 'Converted — not saved.'; + } catch (err) { + convertBtn.disabled = false; + convertBtn.textContent = 'Convert & Download'; + if (!$('xavatar-status').textContent) { + status.textContent = 'Error: ' + err.message; + } + } + }); + + sendBtn.addEventListener('click', async () => { + if (!_xavatarClient?._masterCanvas) return; + convertBtn.disabled = true; + sendBtn.disabled = true; + sendBtn.textContent = 'Working…'; + status.textContent = ''; + try { + // ── Step 1: Convert ────────────────────────────────────────────────── + _xavatarSetProgress(10, 'Converting…'); + status.textContent = 'Converting image to .xavatar…'; + const result = await _xavatarClient.convert(); + _xavatarLastResult = result; + + // ── Step 2: Random filename ────────────────────────────────────────── + const pad = n => String(n).padStart(5, '0'); + const rnd = pad(Math.floor(Math.random() * 100000)); + const rndName = `chimera${rnd}.xavatar`; + _xavatarSetProgress(55, 'Uploading…'); + status.textContent = `Queuing upload → /data/AVATARS/${rndName}…`; + + // ── Step 3: Upload ────────────────────────────────────────────────── + await window.pork.xavatarUploadToPs5(result.buffer, rndName); + _xavatarSetProgress(100, 'Queued ✓'); + status.textContent = `Queued → /data/AVATARS/${rndName}`; + sendBtn.textContent = '↗ Send to PS5'; + sendBtn.disabled = false; + convertBtn.disabled = false; + convertBtn.textContent = 'Convert & Download'; + if ($('xavatar-notify-toggle')?.checked) { + window.pork.psnotifySend('XAvatar Queued', rndName).catch(() => {}); + } + setTimeout(() => _xavatarSetProgress(null), 3000); + } catch (e) { + _xavatarSetProgress(null); + convertBtn.disabled = false; + sendBtn.disabled = false; + sendBtn.textContent = '↗ Send to PS5'; + convertBtn.textContent = 'Convert & Download'; + status.textContent = 'Error: ' + e.message; + } + }); + + $('btn-xavatar-refresh-lib').addEventListener('click', loadXavatarLibrary); +} + +function _xavatarFmtSize(bytes) { + if (!bytes) return ''; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +async function loadXavatarLibrary() { + const listEl = $('xavatar-library-list'); + const emptyEl = $('xavatar-library-empty'); + if (!listEl) return; + + listEl.innerHTML = '
    Loading…
    '; + emptyEl.hidden = true; + + try { + const ftpStatus = await window.pork.ftpStatus().catch(() => ({ connected: false })); + if (!ftpStatus?.connected) { + listEl.innerHTML = ''; + emptyEl.hidden = false; + emptyEl.textContent = 'Connect to PS5 FTP to browse /data/AVATARS.'; + return; + } + + const files = await window.pork.ftpListDir('/data/AVATARS'); + const xavs = files.filter(f => !f.isDir); + + if (!xavs.length) { + listEl.innerHTML = ''; + emptyEl.hidden = false; + return; + } + + emptyEl.hidden = true; + listEl.innerHTML = '
    ' + + xavs.map(f => { + const rpath = `/data/AVATARS/${escHtml(f.name)}`; + return `
    +
    + ${escHtml(f.name)} +
    👤
    +
    +
    + ${escHtml(f.name)} + ${_xavatarFmtSize(f.size)} +
    +
    + + +
    +
    `; + }).join('') + '
    '; + + _setupXavatarThumbObserver(listEl); + } catch (err) { + listEl.innerHTML = ''; + emptyEl.hidden = false; + emptyEl.textContent = 'Failed to load library: ' + err.message; + } +} + +/** Attach IntersectionObserver to lazily load thumbnails as cards scroll into view */ +function _setupXavatarThumbObserver(container) { + const imgs = container.querySelectorAll('img.xavatar-thumb'); + if (!imgs.length) return; + const observer = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + const img = entry.target; + observer.unobserve(img); + _loadXavatarThumb(img, img.dataset.xaPath); + } + }); + }, { rootMargin: '120px', threshold: 0.01 }); + imgs.forEach(img => observer.observe(img)); +} + +/** Fetch + extract avatar.png from a .xavatar on FTP, cache as blob URL */ +async function _getXavatarThumbUrl(rpath) { + let url = _xavatarThumbCache.get(rpath); + if (!url) { + const xavBuf = await window.pork.ftpDownloadFile(rpath); + const pngBuf = await window.pork.xavatarExtractPng(xavBuf); + const blob = new Blob([pngBuf], { type: 'image/png' }); + url = URL.createObjectURL(blob); + _xavatarThumbCache.set(rpath, url); + } + return url; +} + +/** Load one thumbnail into an img element */ +async function _loadXavatarThumb(img, rpath) { + const card = img.closest?.('.xavatar-lib-card'); + try { + const url = await _getXavatarThumbUrl(rpath); + img.src = url; + img.onload = () => card?.classList.add('xavatar-thumb-loaded'); + img.onerror = () => card?.classList.add('xavatar-thumb-error'); + } catch (_) { + card?.classList.add('xavatar-thumb-error'); + } +} + +// ── XAvatar full-size modal ─────────────────────────────────────────────────── + +let _xavatarModalPath = null; + +function _xavatarOpenModal(rpath, name) { + _xavatarModalPath = rpath; + const modal = $('xavatar-thumb-modal'); + const img = $('xavatar-modal-img'); + const namEl = $('xavatar-modal-name'); + img.src = ''; + img.alt = 'Loading…'; + namEl.textContent = name; + openAccessibleDialog(modal, $('btn-xavatar-modal-close')); + // reset modal send button + const sendBtn = $('btn-xavatar-modal-send'); + if (sendBtn) { sendBtn.disabled = false; sendBtn.textContent = '↗ Send to PS5'; } + _getXavatarThumbUrl(rpath) + .then(url => { if (_xavatarModalPath === rpath) { img.src = url; img.alt = name; } }) + .catch(() => { if (_xavatarModalPath === rpath) img.alt = 'Preview unavailable'; }); +} + +// Close modal on backdrop click or close button +document.addEventListener('click', e => { + const modal = $('xavatar-thumb-modal'); + if (!modal || modal.hidden) return; + if (e.target === modal || e.target.id === 'btn-xavatar-modal-close') { + closeAccessibleDialog(modal); + _xavatarModalPath = null; + } +}); +document.addEventListener('keydown', event => { + const modal = $('xavatar-thumb-modal'); + if (event.key === 'Escape' && modal && !modal.hidden) { + closeAccessibleDialog(modal); + _xavatarModalPath = null; + } +}); + +// ── XAvatar: format picker dialog ────────────────────────────────────────── +function _xavatarPickFormat() { + return new Promise(resolve => { + const dlg = document.createElement('dialog'); + dlg.style.cssText = [ + 'border:none', + 'border-radius:10px', + 'padding:22px 26px 18px', + 'background:var(--bg2,#1e1e2e)', + 'color:var(--fg,#cdd6f4)', + 'box-shadow:0 8px 32px rgba(0,0,0,.65)', + 'text-align:center', + 'min-width:260px', + 'position:fixed', + 'top:50%', + 'left:50%', + 'transform:translate(-50%,-50%)', + 'margin:0', + ].join(';'); + dlg.innerHTML = [ + '

    Choose download format

    ', + '
    ', + ' ', + ' ', + ' ', + '
    ', + ].join(''); + dlg.setAttribute('aria-labelledby', '_xa-fmt-title'); + document.body.appendChild(dlg); + dlg.showModal(); + const done = (val) => { dlg.removeEventListener('close', onClose); dlg.close(); dlg.remove(); resolve(val); }; + const onClose = () => { dlg.remove(); resolve(null); }; + dlg.addEventListener('close', onClose); + dlg.querySelector('#_xa-fmt-xav').addEventListener('click', () => done('xavatar')); + dlg.querySelector('#_xa-fmt-png').addEventListener('click', () => done('png')); + dlg.querySelector('#_xa-fmt-cancel').addEventListener('click', () => done(null)); + }); +} + +// Modal action buttons +function _xavatarBindModalButtons() { + const modalDownload = $('btn-xavatar-modal-download'); + const modalToPng = $('btn-xavatar-modal-topng'); + const modalSend = $('btn-xavatar-modal-send'); + if (!modalDownload) return; + + modalDownload.addEventListener('click', async () => { + if (!_xavatarModalPath) return; + const fmt = await _xavatarPickFormat(); + if (!fmt) return; + const name = $('xavatar-modal-name')?.textContent || 'avatar.xavatar'; + modalDownload.disabled = true; + try { + const buf = await window.pork.ftpDownloadFile(_xavatarModalPath); + if (fmt === 'png') { + const pngName = name.replace(/\.xavatar$/i, '') + '.png'; + const pngBuf = await window.pork.xavatarExtractPng(buf); + await window.pork.xavatarSaveFile(pngBuf, pngName); + } else { + await window.pork.xavatarSaveFile(buf, name); + } + } catch (err) { showToast('Download failed: ' + err.message, 'error'); } + modalDownload.disabled = false; + }); + + modalToPng.addEventListener('click', async () => { + if (!_xavatarModalPath) return; + const name = $('xavatar-modal-name')?.textContent || 'avatar'; + const pngName = name.replace(/\.xavatar$/i, '') + '.png'; + modalToPng.disabled = true; + try { + const xavBuf = await window.pork.ftpDownloadFile(_xavatarModalPath); + const pngBuf = await window.pork.xavatarExtractPng(xavBuf); + await window.pork.xavatarSaveFile(pngBuf, pngName); + } catch (err) { showToast('PNG save failed: ' + err.message, 'error'); } + modalToPng.disabled = false; + }); + + modalSend.addEventListener('click', async () => { + if (!_xavatarModalPath) return; + const name = $('xavatar-modal-name')?.textContent || 'avatar.xavatar'; + modalSend.disabled = true; + modalSend.textContent = 'Sending…'; + try { + const buf = await window.pork.ftpDownloadFile(_xavatarModalPath); + await window.pork.xavatarUploadToPs5(buf, name); + modalSend.textContent = '↗ Send to PS5'; + modalSend.disabled = false; + showToast(`Queued: /data/AVATARS/${name}`, 'success'); + } catch (err) { + showToast('Upload failed: ' + err.message, 'error'); + modalSend.textContent = '↗ Send to PS5'; + modalSend.disabled = false; + } + }); +} +// Bind now if the DOM is already parsed, otherwise wait — this module may be +// loaded after DOMContentLoaded has already fired. +if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', _xavatarBindModalButtons); +else _xavatarBindModalButtons(); + +// ── XAvatar: card thumbnail click → open modal ──────────────────────────────── +document.addEventListener('click', e => { + const card = e.target.closest('.xavatar-lib-card'); + if (card && !e.target.closest('[data-xa]')) { + _xavatarOpenModal(card.dataset.xaCardPath, card.dataset.xaCardName); + } +}); + +// ── XAvatar: library card action buttons (↓ download / ✕ delete) ───────────── +document.addEventListener('click', async e => { + const btn = e.target.closest('[data-xa]'); + if (!btn) return; + const action = btn.dataset.xa; + const rpath = btn.dataset.xaPath; + const name = btn.dataset.xaName; + btn.disabled = true; + + try { + if (action === 'download') { + const fmt = await _xavatarPickFormat(); + if (!fmt) { btn.disabled = false; return; } + const buf = await window.pork.ftpDownloadFile(rpath); + if (fmt === 'png') { + const pngName = name.replace(/\.xavatar$/i, '') + '.png'; + const pngBuf = await window.pork.xavatarExtractPng(buf); + await window.pork.xavatarSaveFile(pngBuf, pngName); + } else { + await window.pork.xavatarSaveFile(buf, name); + } + + } else if (action === 'delete') { + // Native browser dialogs are blocked in this app; use the in-app modal. + const confirmation = await showExactConfirmation(`Delete /data/AVATARS/${name} from your PS5?`, 'VERWIJDER VAN PS5'); + if (!confirmation) { btn.disabled = false; return; } + await window.pork.ftpDeleteFile(rpath, confirmation); + btn.closest('.xavatar-lib-card')?.remove(); + _xavatarThumbCache.delete(rpath); + const grid = $('xavatar-library-list')?.querySelector('.xavatar-lib-grid'); + if (grid && !grid.children.length) $('xavatar-library-empty').hidden = false; + } + } catch (err) { + showToast('XAvatar: ' + err.message, 'error'); + } + btn.disabled = false; +}); + diff --git a/renderer/modules/y2jb-generator.js b/renderer/modules/y2jb-generator.js new file mode 100644 index 0000000..2b87254 --- /dev/null +++ b/renderer/modules/y2jb-generator.js @@ -0,0 +1,191 @@ +// ── Y2JB Update Generator ─────────────────────────────────────────────────── + +const y2jbGen = { entries: [], payloads: [], catalog: [] }; +const y2jbGenBytes = value => { + const bytes = Number(value || 0); + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +}; + +function y2jbGenRender() { + const list = $('y2jbgen-list'); + if (!list) return; + if (!y2jbGen.entries.length) { + list.innerHTML = '

    No entries yet. Add payload files, then order payloads, delays, and messages.

    '; + return; + } + list.innerHTML = y2jbGen.entries.map((entry, index) => { + const value = escHtml(entry.value || ''); + const title = entry.type === 'payload' ? 'Payload' : entry.type === 'delay' ? 'Delay (ms)' : 'Message'; + const placeholder = entry.type === 'payload' ? 'payload.elf' : entry.type === 'delay' ? '1000' : 'Shown during autoload'; + return `
    + ${index + 1} + + + + + +
    `; + }).join(''); +} + +function y2jbGenSetStatus(text, type = '') { + const status = $('y2jbgen-status'); + status.textContent = text; + status.className = `hint ${type}`; +} + +async function y2jbGenLoadCatalog() { + const select = $('y2jbgen-catalog'); + if (!select || !window.pork.y2jbGenCatalog) return; + select.disabled = true; + select.innerHTML = ''; + try { + const packet = await window.pork.y2jbGenCatalog(); + y2jbGen.catalog = packet.items || []; + select.innerHTML = y2jbGen.catalog.length + ? y2jbGen.catalog.map(item => ``).join('') + : ''; + y2jbGenSetStatus(y2jbGen.catalog.length + ? `${y2jbGen.catalog.length} local catalog payload${y2jbGen.catalog.length === 1 ? '' : 's'} available.` + : 'The local Payload Catalog is empty.'); + } catch (error) { + select.innerHTML = ''; + y2jbGenSetStatus(`Catalog error: ${error.message}`, 'error'); + } + select.disabled = false; +} + +$('btn-y2jbgen-refresh-catalog').addEventListener('click', y2jbGenLoadCatalog); + +$('btn-y2jb-discover')?.addEventListener('click', async event => { + const button = event.currentTarget; + button.disabled = true; + try { + const release = await window.pork.y2jbDiscover(true); + $('y2jb-upstream-status').innerHTML = `${escHtml(release.version || 'Latest official Y2JB release')}${escHtml(release.source)} · ${y2jbGenBytes(release.size || 0)} · ${release.digest ? 'published SHA-256 available' : 'ZIP validation; no published SHA-256'}`; + } catch (error) { + $('y2jb-upstream-status').innerHTML = `Release discovery unavailable${escHtml(error.message)}`; + } finally { button.disabled = false; } +}); + +$('btn-y2jb-stage')?.addEventListener('click', async event => { + if (!await showConfirm('Download the discovered official Y2JB ZIP, validate it and copy it to the configured PS5 update folder? The update is not executed.')) return; + const button = event.currentTarget; + button.disabled = true; + try { + const result = await window.pork.y2jbApply(); + $('y2jb-upstream-status').innerHTML = `Verified update staged${escHtml(result.version || 'Y2JB')} · ${escHtml(result.remotePath)} · SHA-256 ${escHtml(result.sha256.slice(0, 16))}… · not executed`; + } catch (error) { + $('y2jb-upstream-status').innerHTML = `Staging failed${escHtml(error.message)}`; + } finally { button.disabled = false; } +}); + +$('btn-y2jbgen-add-catalog').addEventListener('click', () => { + const selected = [...$('y2jbgen-catalog').selectedOptions]; + let added = 0; + for (const option of selected) { + const payload = y2jbGen.catalog.find(item => item.id === option.value); + if (!payload || y2jbGen.entries.some(entry => entry.type === 'payload' && entry.value === payload.name)) continue; + y2jbGen.entries.push({ type: 'payload', value: payload.name, catalogId: payload.id }); + added += 1; + } + y2jbGenRender(); + y2jbGenSetStatus(added ? `Added ${added} catalog payload${added === 1 ? '' : 's'} ✓` : 'No new catalog payloads selected.'); +}); + +$('btn-y2jbgen-add-payloads').addEventListener('click', async () => { + const btn = $('btn-y2jbgen-add-payloads'); + btn.disabled = true; + y2jbGenSetStatus('Choosing payload files…'); + try { + const chosen = await window.pork.y2jbGenSelectPayloads(); + let added = 0; + for (const payload of chosen || []) { + if (y2jbGen.payloads.some((item) => item.name === payload.name) + || y2jbGen.entries.some(entry => entry.type === 'payload' && entry.value === payload.name)) continue; + y2jbGen.payloads.push(payload); + y2jbGen.entries.push({ type: 'payload', value: payload.name }); + added += 1; + } + y2jbGenRender(); + y2jbGenSetStatus(added ? `Added ${added} payload file${added === 1 ? '' : 's'} ✓` : 'No new payload files selected.'); + } catch (error) { + y2jbGenSetStatus(`Error: ${error.message}`, 'error'); + } + btn.disabled = false; +}); + +$('btn-y2jbgen-add-delay').addEventListener('click', () => { + y2jbGen.entries.push({ type: 'delay', value: '1000' }); + y2jbGenRender(); +}); + +$('btn-y2jbgen-add-message').addEventListener('click', () => { + y2jbGen.entries.push({ type: 'message', value: 'Starting next payload' }); + y2jbGenRender(); +}); + +$('y2jbgen-list').addEventListener('input', (event) => { + const index = Number(event.target.dataset.y2jbValue); + if (Number.isInteger(index) && y2jbGen.entries[index]) y2jbGen.entries[index].value = event.target.value; +}); + +$('y2jbgen-list').addEventListener('change', (event) => { + const index = Number(event.target.dataset.y2jbType); + if (Number.isInteger(index) && y2jbGen.entries[index]) { + y2jbGen.entries[index].type = event.target.value; + if (event.target.value === 'delay' && !/^\d+$/.test(y2jbGen.entries[index].value)) y2jbGen.entries[index].value = '1000'; + y2jbGenRender(); + } +}); + +$('y2jbgen-list').addEventListener('click', (event) => { + const button = event.target.closest('button'); + if (!button) return; + const up = Number(button.dataset.y2jbUp); + const down = Number(button.dataset.y2jbDown); + const remove = Number(button.dataset.y2jbRemove); + if (Number.isInteger(up) && y2jbGen.entries[up] && up > 0) { + [y2jbGen.entries[up - 1], y2jbGen.entries[up]] = [y2jbGen.entries[up], y2jbGen.entries[up - 1]]; + } else if (Number.isInteger(down) && y2jbGen.entries[down] && down < y2jbGen.entries.length - 1) { + [y2jbGen.entries[down + 1], y2jbGen.entries[down]] = [y2jbGen.entries[down], y2jbGen.entries[down + 1]]; + } else if (Number.isInteger(remove) && y2jbGen.entries[remove]) { + y2jbGen.entries.splice(remove, 1); + } else { + return; + } + y2jbGenRender(); +}); + +$('btn-y2jbgen-build').addEventListener('click', async () => { + const btn = $('btn-y2jbgen-build'); + btn.disabled = true; + y2jbGenSetStatus('Validating and building ZIP…'); + try { + const referenced = new Set(y2jbGen.entries.filter(entry => entry.type === 'payload').map(entry => entry.value)); + const catalogPayloadIds = y2jbGen.entries + .filter(entry => entry.type === 'payload' && entry.catalogId && referenced.has(entry.value)) + .map(entry => entry.catalogId); + const result = await window.pork.y2jbGenBuild({ + entries: y2jbGen.entries.map(({ catalogId, ...entry }) => entry), + payloads: y2jbGen.payloads.filter(payload => referenced.has(payload.name)), + catalogPayloadIds, + }); + if (result?.canceled) { + y2jbGenSetStatus('Save cancelled.'); + } else { + y2jbGenSetStatus(`Built ✓ ${result.outputZip} (${result.entryCount} entries)`); + } + } catch (error) { + y2jbGenSetStatus(`Error: ${error.message}`, 'error'); + } + btn.disabled = false; +}); + +y2jbGenLoadCatalog(); diff --git a/renderer/offline.html b/renderer/offline.html new file mode 100644 index 0000000..cb42028 --- /dev/null +++ b/renderer/offline.html @@ -0,0 +1,11 @@ + + + + + + + Chimera · offline + + +
    Chimera

    Chimera is offline

    The secure offline shell intentionally contains no console data, credentials, cached API responses or mutating controls. Restore the HTTPS connection to continue.

    Try again
    + diff --git a/renderer/pork-evidence-bridge.js b/renderer/pork-evidence-bridge.js new file mode 100644 index 0000000..5a96e5f --- /dev/null +++ b/renderer/pork-evidence-bridge.js @@ -0,0 +1 @@ +(()=>{const p=window.pork;if(!p?.isWebMode)return;p.ps5NativePackageRetainedLaunchEvidence=async operationId=>{const r=await fetch('/api/commands/ps5-native-packages%3Aretained-launch-evidence',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({args:[{operationId}]})});const d=await r.json();if(!r.ok||d.ok!==true){if(r.status===401)location.replace('/server-login.html');throw Object.assign(new Error(d.error||`HTTP ${r.status}`),{code:d.code||'COMMAND_FAILED'});}return d.result;};p.download=(buffer,filename)=>{const url=URL.createObjectURL(new Blob([buffer],{type:'application/json'}));const a=Object.assign(document.createElement('a'),{href:url,download:filename});document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),10000);};})(); diff --git a/renderer/pork-web-shim.js b/renderer/pork-web-shim.js new file mode 100644 index 0000000..6804f3c --- /dev/null +++ b/renderer/pork-web-shim.js @@ -0,0 +1 @@ +!function(){"use strict";if(window.pork)return;let e=null;const t=fetch("/api/auth/status",{credentials:"same-origin"}).then(async t=>{const a=await t.json();if(!a.authenticated)throw location.replace("/server-login.html"),new Error("Authentication required");return e=a.csrfToken,a}),a=new Map;let o=null;function s(e){let t="";for(let a=0;ar(e));if("object"==typeof e){const t={};for(const[a,o]of Object.entries(e))t[a]=r(o);return t}return e}function i(e){if(!e||"object"!=typeof e)return e;if(!0===e.__porkBinary&&"string"==typeof e.base64)return function(e){const t=atob(e),a=new Uint8Array(t.length);for(let e=0;ei(e));const t={};for(const[a,o]of Object.entries(e))t[a]=i(o);return t}async function n(a,...o){await t;const s=await fetch(`/api/commands/${encodeURIComponent(a)}`,{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json","X-CSRF-Token":e},body:JSON.stringify({args:r(o)})});if(401===s.status)throw location.replace("/server-login.html"),new Error("Authentication required");const n=await s.json();if(!n.ok){const e=new Error(n.error||"IPC error");throw e.status=s.status,e.code=n.code||"COMMAND_FAILED",e.expected=["NOT_CONFIGURED","UNAVAILABLE","UPSTREAM_UNAVAILABLE"].includes(e.code),e}return i(n.result)}async function p(a,{method:o="GET",body:s}={}){await t;const r={"X-CSRF-Token":e};void 0!==s&&(r["Content-Type"]="application/json");const i=await fetch(a,{method:o,credentials:"same-origin",headers:r,body:void 0===s?void 0:JSON.stringify(s)});if(401===i.status)throw location.replace("/server-login.html"),new Error("Authentication required");const n=await i.json();if(!i.ok||!1===n.ok)throw new Error(n.error||`HTTP ${i.status}`);return n}function c(e){return"string"==typeof e&&e.startsWith("pork-cache://")?"/pork-cache/"+e.slice(13):e}async function l(e){const t=await e;if(t?.downloadUrl){const e=document.createElement("a");e.href=t.downloadUrl,e.download=t.filename||"",e.style.display="none",document.body.appendChild(e),e.click(),e.remove()}return t}async function m(e){const t=await l(e);return t?.filename||t}function d(e=""){return new Promise((t,a)=>{const o=document.createElement("input");o.type="file",o.accept=e,o.style.display="none",o.addEventListener("change",async()=>{try{const e=o.files?.[0];t(e?{filename:e.name,data:new Uint8Array(await e.arrayBuffer())}:null)}catch(e){a(e)}finally{o.remove()}},{once:!0}),o.addEventListener("cancel",()=>{o.remove(),t(null)},{once:!0}),document.body.appendChild(o),o.click()})}function u(e=""){return new Promise((t,a)=>{const o=document.createElement("input");o.type="file",o.multiple=!0,o.accept=e,o.style.display="none",o.addEventListener("change",async()=>{try{const e=[...o.files||[]];t(await Promise.all(e.map(async e=>({name:e.name,data:new Uint8Array(await e.arrayBuffer())}))))}catch(e){a(e)}finally{o.remove()}},{once:!0}),o.addEventListener("cancel",()=>{o.remove(),t([])},{once:!0}),document.body.appendChild(o),o.click()})}function y(e,t){const a=window.prompt(`${e}\n\nGebruik een pad dat als volume in de Chimera-container is gekoppeld.`,t||"/data");return Promise.resolve(null!=a&&String(a).trim()?String(a).trim():null)}function h(e){const t=Object.getOwnPropertyDescriptor(e,"src");if(!t||!t.set)return;const a=t.set;Object.defineProperty(e,"src",{...t,set(e){a.call(this,c(e))}})}!function e(){const t="https:"===location.protocol?"wss:":"ws:";o=new WebSocket(`${t}//${location.host}/ws`),o.onmessage=e=>{try{const{channel:t,args:o}=JSON.parse(e.data),s=a.get(t);if(s)for(const e of s)e(...o||[])}catch(e){}},o.onclose=()=>setTimeout(e,2e3),o.onerror=()=>{try{o.close()}catch(e){}}}(),h(HTMLVideoElement.prototype),h(HTMLImageElement.prototype);const g=Element.prototype.setAttribute;Element.prototype.setAttribute=function(e,t){return"src"===e&&(t=c(t)),g.call(this,e,t)},window.pork={isWebMode:!0,minimize:()=>{},maximize:()=>{},close:()=>{},getSettings:()=>n("settings:get"),setSettings:e=>n("settings:set",e),isAdmin:()=>Promise.resolve(!0),ftpConnect:e=>n("ftp:connect",e),ftpDisconnect:()=>n("ftp:disconnect"),ftpStatus:()=>n("ftp:status"),ftpScan:()=>n("ftp:scan"),ftpDownload:e=>n("ftp:download",e),ftpDownloadFolder:e=>n("ftp:download-folder",{game_id:e}),ftpUpload:e=>n("ftp:upload",e),ftpInstallPfs:e=>n("ftp:install-pfs",e),ftpListDir:e=>n("ftp:list-dir",{path:e}),ftpFindGame:e=>n("ftp:find-game",{game_id:e}),ftpDeleteGame:(e,t)=>n("ftp:delete-game",{game_id:e,confirmation:t}),ftpDownloadFile:e=>n("ftp:download-file",{remotePath:e}),ftpDeleteFile:(e,t)=>n("ftp:delete-file",{remotePath:e,confirmation:t}),listGames:e=>n("db:games:list",e),listLibraryTitles:e=>n("library:titles:list",e),etahenLinkInventoryStatus:()=>n("etahen-link:inventory:status"),listStorageInventory:e=>n("storage:inventory:list",e||{}),labelStorageDevice:e=>n("storage:inventory:label",e||{}),storageGameLocator:e=>n("storage:locator:list",e||{}),guidedMoveList:e=>n("guided-move:list",e||{}),guidedMoveCreate:e=>n("guided-move:create",e||{}),guidedMoveCancel:e=>n("guided-move:cancel",{id:e}),guidedMoveReconcile:e=>n("guided-move:reconcile",{id:e}),gameMoveCapabilities:()=>n("game-move:capabilities"),installDestinations:e=>n("install:destinations:list",e||{}),packageRecipesList:e=>n("package-recipes:list",{titleId:e}),packageCatalog:()=>n("package-catalog:list"),compatibilityGuidance:e=>n("compatibility:guidance",e||{}),packageRecipeSave:e=>n("package-recipes:save",e),ps4PackageBuilderStatus:()=>n("ps4-packages:builder-status"),ps4PackageInspect:e=>n("ps4-packages:inspect",e),ps4PackageCreate:e=>n("ps4-packages:create",e),ps4PackageJobs:()=>n("ps4-packages:list"),ps4PackageJob:e=>n("ps4-packages:get",{id:e}),ps4PackageRun:e=>n("ps4-packages:run",{id:e}),ps4PackageResume:e=>n("ps4-packages:resume",{id:e}),ps4PackageRestorePlan:e=>n("ps4-packages:restore-plan",e),ps4PackageRestore:e=>n("ps4-packages:restore",e),ps4PackageRestoreVerify:e=>n("ps4-packages:restore-verify",{titleId:e}),ps5NativePackageBuilderStatus:()=>n("ps5-native-packages:builder-status"),ps5NativePackageInspectBackup:e=>n("ps5-native-packages:inspect-backup",e),ps5NativePackageInspectFile:e=>n("ps5-native-packages:inspect-package",{filePath:e}),ps5PackageComparison:e=>n("ps5-native-packages:compare",e),ps5UpstreamControl:(e,t)=>n(`ps5-native-packages:control-${e}`,t),ps5A8DistinctControl:(e,t)=>n(`ps5-native-packages:a8-distinct-control-${e}`,t),ps5A10TransitionControl:(e,t)=>n(`ps5-native-packages:a10-transition-control-${e}`,t),ps5LaunchReference:(e,t)=>n(`ps5-native-packages:launch-reference-${e}`,t),ps5NativePackageCreate:e=>n("ps5-native-packages:create",e),ps5NativePackageJobs:()=>n("ps5-native-packages:list"),ps5NativePackageJob:e=>n("ps5-native-packages:get",{id:e}),ps5NativePackageRun:e=>n("ps5-native-packages:run",{id:e}),ps5NativePackageResume:e=>n("ps5-native-packages:resume",{id:e}),ps5ConsoleStagingProbe:()=>n("ps5-console-staging:probe"),ps5ConsoleStagingDiscover:e=>n("ps5-console-staging:discover",{titleId:e}),ps5ConsoleStagingCreate:e=>n("ps5-console-staging:create",e),ps5ConsoleStagingJobs:()=>n("ps5-console-staging:list"),ps5ConsoleStagingJob:e=>n("ps5-console-staging:get",{id:e}),ps5ConsoleStagingRun:e=>n("ps5-console-staging:run",{id:e}),ps5ConsoleStagingResume:e=>n("ps5-console-staging:resume",{id:e}),ps5NativePackageRestorePlan:e=>n("ps5-native-packages:restore-plan",e),ps5NativePackageRestore:e=>n("ps5-native-packages:restore",e),ps5NativePackageRestoreStatus:e=>n("ps5-native-packages:restore-status",{operationId:e}),ps5NativePackageRestoreList:()=>n("ps5-native-packages:restore-list"),ps5NativePackageRestoreCancel:(e,t)=>n("ps5-native-packages:restore-cancel",{operationId:e,confirmation:t}),ps5NativePackageRestoreVerify:e=>n("ps5-native-packages:restore-verify","object"==typeof e?e:{titleId:e}),ps5NativePackageRestoreLaunchPlan:e=>n("ps5-native-packages:restore-launch-plan",{operationId:e}),ps5NativePackageRestoreLaunch:e=>n("ps5-native-packages:restore-launch",e),ps5NativePackageRestoreLaunchStatus:e=>n("ps5-native-packages:restore-launch-status",{operationId:e}),recordSemanticEvidence:e=>n("library:evidence:override",e),listSemanticEvidence:e=>n("library:evidence:list",e||{}),legacySemanticDiagnostics:()=>n("library:legacy-diagnostics"),gameCatalogHealth:()=>n("games:catalog-health"),updateGame:e=>n("db:games:update",e),updateGameMetadata:(e,t)=>n("games:metadata-override",{game_id:e,fields:t}),listBackups:e=>n("db:backups:list",e),deleteBackup:(e,t)=>n("db:backups:delete",{id:e,confirmation:t}),addBackup:(e,t)=>n("db:backups:upsert",{game_id:e,backup_path:t}),getStats:()=>n("db:stats"),dbQuery:e=>n("db:query",{sql:e}),dbClear:e=>n("db:clear",{confirmation:e}),dbClearSelective:(e,t)=>n("db:clear-selective",{...e,confirmation:t}),dbDeleteRows:(e,t,a)=>n("db:delete-rows",{table:e,ids:t,confirmation:a}),dbClearHashes:e=>n("db:clear-hashes",{confirmation:e}),exportSql:()=>m(n("db:export:sql")),exportCsv:e=>m(n("db:export:csv",{table:e})),scanBackups:()=>n("local:scan-backups"),selectFolder:()=>y("Servermap (bijv. /game-backups, /payloads of /import):","/game-backups"),openShell:e=>(/^https?:\/\//i.test(String(e||""))&&window.open(e,"_blank","noopener,noreferrer"),Promise.resolve({ok:!0})),prosperoFetch:e=>n("prospero:fetch",{game_id:e}),prosperoFetchIcon:e=>n("prospero:fetch-icon",{game_id:e}),prosperoRefreshAll:()=>n("prospero:refresh-all"),backporksList:()=>n("backporks:list"),backporksAdd:(e,t)=>n("backporks:add",{name:e,path:t}),backporksAddRoot:e=>n("backporks:add-root",{rootPath:e}),backporksRemove:(e,t)=>n("backporks:remove",{id:e,confirmation:t}),backporksScan:e=>n("backporks:scan",{id:e}),backporksScanAll:()=>n("backporks:scan-all"),backporksGames:e=>n("backporks:games",{folder:e}),backporksEntries:e=>n("backporks:entries",{game_id:e}),backporksPork:(e,t,a)=>n("backporks:pork",{game_id:e,folder_name:t,folder_path:a}),backporksCreateFolder:(e,t)=>n("backporks:create-folder",{name:e,parentPath:t}),firmwareLabels:()=>n("db:firmware:labels"),hashStatus:e=>n("hash:status",{game_id:e}),hashCompute:(e,t)=>n("hash:compute",{backup_path:e,game_id:t}),hashAdd:e=>n("hash:add",e),hashExport:()=>m(n("hash:export")),hashBulk:e=>n("hash:bulk",{source:e}),hashImportCommunity:async()=>{const e=await d(".json,application/json");return e?n("hash:import-community",e):null},hashCommunityInfo:()=>n("hash:community-info"),hashGameSummary:()=>n("hash:game-summary"),savemgrPushPayload:e=>n("savemgr:push-payload",e),savemgrStart:()=>n("savemgr:start"),savemgrRequest:e=>n("savemgr:request",{path:e}),savemgrUpload:(e,t)=>n("savemgr:upload",{path:e,data:t}),savemgrDownload:(e,t)=>l(n("savemgr:download",{path:e,filename:t})),savemgrDecrypt:(e,t)=>l(n("savemgr:decrypt",{data:e,filename:t})),savemgrResign:(e,t,a)=>l(n("savemgr:resign",{data:e,aid:t,filename:a})),savemgrIcon:()=>n("savemgr:icon"),savemgrDumpUsb:e=>n("savemgr:dump-usb",{idx:e}),savemgrCreatePfs:e=>n("savemgr:create-pfs",{size:e}),savemgrDownloadNew:(e,t)=>l(n("savemgr:download-new",{name:e,aid:t})),psnotifySend:(e,t)=>n("psnotify:send",{message:e,subMessage:t}),psnotifyHistory:()=>n("psnotify:history"),psnotifyTest:()=>n("psnotify:test"),psnotifyPushPayload:()=>n("psnotify:pushPayload"),y2jbCheck:()=>n("y2jb:check"),y2jbApply:()=>n("y2jb:apply"),y2jbDiscover:(e=!1)=>n("y2jb:discover",{refresh:e}),y2jbGenCatalog:()=>n("y2jbgen:catalog"),y2jbGenSelectPayloads:()=>u(".elf,.bin,.js,.jar"),y2jbGenBuild:e=>l(n("y2jbgen:build",e)),systemStateStatus:()=>n("system-state:status"),systemStateDeploy:()=>n("system-state:deploy"),systemStateCommand:e=>n("system-state:command",e),bdjbGenSelectPayloads:()=>u(".elf,.bin"),bdjbGenBuild:e=>l(n("bdjbgen:build",e)),transferState:()=>n("transfer:state"),transferPause:()=>n("transfer:pause"),transferResume:()=>n("transfer:resume"),transferCancel:e=>n("transfer:cancel",{id:e}),transferClearDone:()=>n("transfer:clear-done"),transferSetConcurrent:e=>n("transfer:set-concurrent",{n:e}),transferRetry:e=>n("transfer:retry",{id:e}),transferSetPolicy:e=>n("transfer:set-policy",e),payloadSourcesList:()=>n("payload:sources:list"),payloadSourcesAdd:(e,t)=>n("payload:sources:add",{name:e,url:t}),payloadSourcesRemove:(e,t)=>n("payload:sources:remove",{id:e,confirmation:t}),payloadSourcesToggle:e=>n("payload:sources:toggle",{id:e}),payloadCheckUpdates:e=>n("payload:check-updates",null!=e?{id:e}:{}),payloadCatalog:()=>n("payload:catalog"),payloadUpdateDownloaded:()=>n("payload:update-downloaded"),payloadDownload:e=>n("payload:download",e),payloadListLocal:()=>n("payload:list-local"),payloadListRemote:()=>n("payload:list-remote"),payloadPush:e=>n("payload:push",e),payloadDeleteRemote:(e,t)=>n("payload:delete-remote",{filename:e,confirmation:t}),payloadDeleteLocal:e=>n("payload:delete-local",e),ps5ServiceMatrix:()=>n("system:service-matrix"),openSystemViewPopout:()=>(window.open("/system-view.html","chimera-system-view","popup=yes,width=1280,height=800,noopener,noreferrer"),Promise.resolve({ok:!0})),cheatsStats:()=>n("cheats:stats"),cheatsForGame:e=>n("cheats:for-game",{cusa_id:e}),cheatsSearch:e=>n("cheats:search",{query:e}),cheatsAll:()=>n("cheats:all"),cheatsFetchIndex:()=>n("cheats:fetch-index"),cheatsDownloadAll:()=>n("cheats:download-all"),cheatsCancel:()=>n("cheats:cancel"),cheatsUnmatched:()=>n("cheats:unmatched"),cheatsAssign:(e,t)=>n("cheats:assign",{filename:e,cusaId:t}),cheatsFtpInstall:e=>n("cheats:ftp-install",{remotePath:e}),mediaCatalog:()=>n("media:catalog"),mediaScan:e=>n("media:scan",e||{}),mediaScanCancel:()=>n("media:scan:cancel"),mediaDownload:e=>n("media:download",e),mediaFetchThumb:e=>n("media:fetch-thumb",{remotePath:e}),mediaGetCachePath:e=>n("media:get-cache-path",{remotePath:e}),mediaFetchVideoPreview:e=>n("media:fetch-video-preview",{remotePath:e}),mediaOpen:async e=>{const t=await n("media:open",{remotePath:e});return t?.url&&window.open(t.url,"_blank","noopener,noreferrer"),t},mediaDiscordSend:e=>n("media:discord-send",e),mediaFetchClipThumbs:e=>n("media:batch-clip-thumbs",e),mediaLoadClipThumbs:()=>n("media:clip-thumbs:load"),mediaCacheStatus:()=>n("media:cache:status"),mediaCachePin:(e,t)=>n("media:cache:pin",{id:e,pinned:t}),mediaCacheClear:e=>n("media:cache:clear",e||{}),mediaCacheRetention:e=>n("media:cache:retention",e||{}),mediaImportsList:(e="")=>p("/api/media/imports"+(e?`?titleId=${encodeURIComponent(e)}`:"")).then(e=>e.result),mediaImportFinalize:e=>p("/api/media/imports/finalize",{method:"POST",body:e}).then(e=>e.result),mediaImportPreferred:(e,t)=>p("/api/media/imports/preferred",{method:"POST",body:{titleId:e,assetId:t}}).then(e=>e.result),autoloaderList:()=>n("autoloader:list"),autoloaderRead:()=>n("autoloader:read"),autoloaderSave:e=>n("autoloader:save",{content:e}),autoloaderSaveLocal:e=>n("autoloader:save-local",e),payloadManagerAutoloadRepairPlan:()=>n("payload-manager:autoload-repair-plan"),payloadManagerAutoloadRepairApply:e=>n("payload-manager:autoload-repair-apply",e),payloadManagerVerifiedStart:e=>n("payload-manager:verified-start",e),payloadManagerAutoloadRollbackPlan:()=>n("payload-manager:autoload-repair-rollback-plan"),payloadManagerAutoloadRollbackApply:e=>n("payload-manager:autoload-repair-rollback",e),etaHenAutoloadPersistencePlan:()=>n("etahen:autoload-persistence-plan"),etaHenAutoloadPersistenceApply:e=>n("etahen:autoload-persistence-apply",e),etaHenDirectStart:e=>n("etahen:direct-start",e),etaHenAutoloadPersistenceRollbackPlan:()=>n("etahen:autoload-persistence-rollback-plan"),etaHenAutoloadPersistenceRollback:e=>n("etahen:autoload-persistence-rollback",e),snapshotTake:e=>n("snapshot:autoloader:take",{label:e}),snapshotList:()=>n("snapshot:autoloader:list"),snapshotRestore:(e,t)=>n("snapshot:autoloader:restore",{id:e,confirmation:t}),snapshotDelete:(e,t)=>n("snapshot:autoloader:delete",{id:e,confirmation:t}),snapshotExport:e=>l(n("snapshot:autoloader:export",{id:e})),snapshotImport:async e=>{if(e?.data)return n("snapshot:autoloader:import",e);const t=await d(".porksnap,.zip,application/zip");return t?n("snapshot:autoloader:import",{...t,label:e?.label||""}):{canceled:!0}},langList:()=>n("lang:list"),langLoad:e=>n("lang:load",e),langExportBase:()=>m(n("lang:export-base")),langImport:async()=>{const e=await d(".json,application/json");return e?n("lang:import",e):{canceled:!0}},payloadTcpSend:e=>n("payload:tcp-send",e),pldmgrProbe:()=>n("pldmgr:probe"),pldmgrObservability:()=>n("pldmgr:observability"),pldmgrLaunchPreflight:e=>n("pldmgr:launch-preflight",{path:e}),pldmgrEmbedUrl:()=>n("pldmgr:embed-url"),pldmgrRequest:e=>n("pldmgr:request",{route:e}),pldmgrLaunch:(e,t=!1,a="")=>n("pldmgr:launch",{path:e,confirmConflict:t,confirmation:a}),xavatarUploadToPs5:(e,t)=>n("xavatar:upload-to-ps5",{buffer:e,filename:t}),xavatarSaveFile:(e,t)=>l(n("xavatar:save-file",{buffer:e,filename:t})),xavatarConvertCanvas:(e,t,a,o)=>n("xavatar:convert-rgba",{rgba:e,width:t,height:a,filename:o}),xavatarExtractPng:e=>n("xavatar:extract-png",{buffer:e}),xavatarCompanion:()=>n("xavatar:companion"),xavatarInstallCompanion:(e="")=>n("xavatar:install-companion",{confirmation:e}),ps5ProfileInspect:()=>n("ps5-profile:inspect"),ps5ProfileRuntimeStatus:()=>n("ps5-profile:runtime-status"),ps5ProfileRuntimePreflight:()=>n("ps5-profile:runtime-preflight"),ps5ProfileStartPayload:e=>n("ps5-profile:start-payload",e),ps5P15A10:(e,t)=>n(`ps5-p15-a10:${e}`,t),ps5P15R2Status:()=>n("ps5-p15-r2:status"),ps5P15R2Preflight:()=>n("ps5-p15-r2:preflight"),ps5P15R2Qualify:()=>n("ps5-p15-r2:qualify"),ps5ProfileCurrentAvatar:e=>n("ps5-profile:current-avatar",e),ps5ProfileListOperations:()=>n("ps5-profile:list-operations"),ps5ProfilePrepareRename:e=>n("ps5-profile:prepare-rename",e),ps5ProfilePrepareAvatar:e=>n("ps5-profile:prepare-avatar",e),ps5ProfileApply:e=>n("ps5-profile:apply",e),ps5ProfileRollback:e=>n("ps5-profile:rollback",e),convToolInfo:()=>n("conv:tool-info"),convExfatToolInfo:()=>n("conv:exfat-tool-info"),convToolPick:()=>Promise.resolve(null),convExfatToolPick:()=>Promise.resolve(null),convPickOutputDir:async()=>{const e=await y("Map voor conversie-uitvoer:","/export");return e&&await n("settings:set",{convOutputDir:e}),e},convPickTempDir:async()=>{const e=await y("Tijdelijke servermap:","/data/tmp");return e&&await n("settings:set",{convTempDir:e}),e},convQueueAdd:e=>n("conv:queue:add",e),convQueueAddBatch:e=>n("conv:queue:add-batch",e),convQueueList:()=>n("conv:queue:list"),convQueueIsPaused:()=>n("conv:queue:is-paused"),convQueueStart:()=>n("conv:queue:start"),convQueueCancel:e=>n("conv:queue:cancel",{id:e}),convQueueClearDone:()=>n("conv:queue:clear-done"),convQueueRetry:e=>n("conv:queue:retry",{id:e}),convQueueCleanupTemp:()=>n("conv:queue:cleanup-temp"),garlicStart:()=>n("garlic:start"),eaVersion:()=>n("ea:version"),eaStart:()=>n("ea:start"),eaEmbedUrl:()=>n("ea:embed-url"),eaList:e=>n("ea:list",{path:e}),eaStat:e=>n("ea:stat",{path:e}),eaUsb:()=>n("ea:usb"),eaMkdir:e=>n("ea:mkdir",{path:e}),eaRename:(e,t)=>n("ea:rename",{src:e,dst:t}),eaDelete:(e,t)=>n("ea:delete",{path:e,recursive:t,confirmation:"VERWIJDER"}),eaCopy:(e,t)=>n("ea:copy",{src:e,dst:t}),eaMove:(e,t)=>n("ea:move",{src:e,dst:t}),eaJobStatus:()=>n("ea:job-status"),eaJobCancel:()=>n("ea:job-cancel"),eaLaunch:(e,t,a)=>n("ea:launch",{path:e,args:t,daemon:a}),eaDownload:(e,t)=>l(n("ea:download",{path:e,filename:t})),eaUpload:async(e,t)=>{const a=await d();if(!a)return null;return n("ea:upload",{remotePath:String(t||"/data/").endsWith("/")?`${t||"/data/"}${a.filename}`:String(t),filename:a.filename,data:a.data})},pfsFoldersList:()=>n("pfsripper:folders:list"),pfsFoldersAdd:async()=>{const e=await y("PFS-bibliotheekmap:","/import");return e?n("pfsripper:folders:add",{folder:e}):null},pfsFoldersRemove:e=>n("pfsripper:folders:remove",{folder:e}),pfsScan:()=>n("pfsripper:scan"),pfsExportCopy:e=>n("pfsripper:export-copy",{path:e}),pfsExportFtp:(e,t)=>n("pfsripper:export-ftp",{path:e,remoteDir:t}),pfsReveal:e=>n("pfsripper:reveal",{path:e}),operationsStatus:()=>n("operations:status"),operationsRuns:e=>n("operations:runs",{limit:e}),operationsConfigureTask:(e,t)=>n("operations:task:configure",{taskId:e,...t}),operationsRunTask:e=>n("operations:task:run",{taskId:e}),backupReplicationStatus:()=>n("backup-replication:status"),backupReplicationRun:e=>n("backup-replication:run",e||{}),backupReplicationVerify:e=>n("backup-replication:verify",{id:e}),backupReplicationRestoreDrill:e=>n("backup-replication:restore-drill",{id:e}),backupReplicationRetention:()=>n("backup-replication:retention-preview"),backupReplicationRetentionSave:e=>n("backup-replication:retention-configure",e),monitoringSnapshot:()=>n("monitoring:snapshot"),monitoringTokenStatus:()=>n("monitoring:token-status"),monitoringTokenRotate:()=>n("monitoring:token-rotate"),commandCatalog:()=>p("/api/commands/catalog"),authSessions:()=>p("/api/auth/sessions"),authSessionRevoke:e=>p("/api/auth/sessions/revoke",{method:"POST",body:{id:e}}),authPasswordChange:(e,t)=>p("/api/auth/password",{method:"POST",body:{currentPassword:e,newPassword:t}}),importUploadCreate:e=>p("/api/import/uploads",{method:"POST",body:{files:e}}).then(e=>e.result),importUploadList:()=>p("/api/import/uploads").then(e=>e.result),importUploadGet:e=>p(`/api/import/uploads/${encodeURIComponent(e)}`).then(e=>e.result),importUploadCancel:e=>p(`/api/import/uploads/${encodeURIComponent(e)}`,{method:"DELETE"}).then(e=>e.result),importUploadChunk:(a,o,s,r,i)=>async function(a,o,s,r,i){await t;const n=await fetch(`/api/import/uploads/${encodeURIComponent(a)}/files/${encodeURIComponent(o)}`,{method:"PUT",credentials:"same-origin",body:r,headers:{"X-CSRF-Token":e,"Upload-Offset":String(s),"X-Chunk-SHA256":i,"Content-Type":"application/octet-stream"}});if(401===n.status)throw location.replace("/server-login.html"),new Error("Authentication required");const p=await n.json();if(!n.ok||!1===p.ok)throw new Error(p.error||`HTTP ${n.status}`);return{...p.result,uploadOffset:Number(n.headers.get("upload-offset")||s)}}(a,o,s,r,i),importJobCreate:e=>p("/api/import/jobs",{method:"POST",body:{uploadSessionId:e}}).then(e=>e.result),importJobList:()=>p("/api/import/jobs").then(e=>e.result),importJobGet:e=>p(`/api/import/jobs/${encodeURIComponent(e)}`).then(e=>e.result),importClassify:e=>p(`/api/import/jobs/${encodeURIComponent(e)}/classification`,{method:"POST",body:{}}).then(e=>e.result),importClassificationGet:e=>p(`/api/import/jobs/${encodeURIComponent(e)}/classification`).then(e=>e.result),importManifestPreview:(e,t)=>p(`/api/import/jobs/${encodeURIComponent(e)}/manifest/preview`,{method:"POST",body:t}).then(e=>e.result),importManifestCreate:(e,t)=>p(`/api/import/jobs/${encodeURIComponent(e)}/manifest`,{method:"POST",body:t}).then(e=>e.result),importDestinations:()=>p("/api/import/destinations").then(e=>e.result),importJobValidate:e=>p(`/api/import/jobs/${encodeURIComponent(e)}/validate`,{method:"POST",body:{inspectionPassed:!0,userValidated:!0}}).then(e=>e.result),importJobFinalize:(e,t)=>p(`/api/import/jobs/${encodeURIComponent(e)}/finalize`,{method:"POST",body:t}).then(e=>e.result),recoveryList:()=>n("recovery:list"),recoveryCreate:e=>n("recovery:create",e||{}),recoveryVerify:e=>n("recovery:verify",{id:e}),recoveryImport:async()=>{const e=await y("Recovery ZIP op de server:","/import/chimera-recovery.zip");return e?n("recovery:import",{path:e}):null},recoveryExport:e=>l(n("recovery:export",{id:e})),recoveryRestore:(e,t)=>n("recovery:restore",{id:e,restoreRemote:t,confirmation:"HERSTEL"}),recoveryDelete:e=>n("recovery:delete",{id:e,confirmation:"VERWIJDER"}),storageAudit:()=>n("storage:audit"),storageStatus:()=>n("storage:status"),compatibilityGet:()=>n("compatibility:get"),compatibilitySet:e=>n("compatibility:set",e),shadowMountStatus:()=>n("shadowmount:status"),shadowMountProposal:()=>n("shadowmount:proposal"),shadowMountPreflight:e=>n("shadowmount:preflight",{config:e}),shadowMountSave:e=>n("shadowmount:save",e),shadowMountLogs:()=>n("shadowmount:logs"),packagesStatus:()=>n("packages:status"),packagesList:()=>n("packages:list"),packagesProgress:()=>n("packages:progress"),packagesInstallUrl:e=>n("packages:install-url",{url:e}),gameUpdatesCatalog:()=>n("game-updates:catalog"),gameUpdatesInspect:(e,t="")=>n("game-updates:manifest:inspect",{url:e,gameId:t}),gameUpdatesAutoInspect:(e,t=!1)=>n("game-updates:manifest:auto-inspect",{gameId:e,refresh:t}),gameUpdatesDiscover:(e,t=!1)=>n("game-updates:manifest:discover",{gameId:e,refresh:t}),gameUpdatesRefreshAll:()=>n("game-updates:manifest:refresh-all",{}),gameUpdatesDownload:e=>n("game-updates:download",{token:e}),gameUpdatesJobs:()=>n("game-updates:jobs"),gameUpdatesCancel:e=>n("game-updates:cancel",{id:e}),gameUpdatesStage:e=>n("game-updates:stage",{id:e}),gameUpdatesHttpsHandoff:e=>n("game-updates:https-handoff",{id:e}),gameUpdatesDelete:e=>n("game-updates:delete",{id:e,confirmation:"VERWIJDER"}),diagnosticsSummary:()=>n("diagnostics:summary"),diagnosticsLogs:e=>n("diagnostics:logs",{limit:e}),diagnosticsExport:()=>l(n("diagnostics:export")),auditQuery:e=>n("audit:query",e||{}),auditHealth:()=>n("audit:health"),auditExport:e=>l(n("audit:export",e||{})),klogStatus:()=>n("klog:status"),klogStart:e=>n("klog:start",e||{}),klogStop:()=>n("klog:stop"),klogQuery:e=>n("klog:query",e||{}),klogExport:e=>l(n("klog:export",e||{})),ecosystemOverview:()=>n("ecosystem:overview"),ecosystemServices:()=>n("ecosystem:services"),ecosystemComponents:()=>n("ecosystem:components:list"),ecosystemRefreshComponents:(e=!1)=>n("ecosystem:components:refresh",{force:e}),ecosystemInventory:()=>n("ecosystem:inventory"),trophyActiveCatalogStatus:()=>n("trophy:active-catalog:status"),trophyActiveCatalog:()=>n("trophy:active-catalog"),trophyInstalledCatalogStatus:()=>n("trophy:installed-catalog:status"),trophyInstalledCatalog:()=>n("trophy:installed-catalog"),trophyImportedCatalog:()=>n("trophy:imported-catalog:list"),trophyImportDefinition:e=>n("trophy:imported-catalog:import",{package:e}),trophyUnlockOne:e=>n("trophy:unlock-one",e),trophyHistoryImportStatus:()=>n("trophy:history-import:status"),trophyHistoryAuthenticate:e=>n("trophy:history-import:authenticate",e),trophyHistoryRevoke:()=>n("trophy:history-import:revoke"),trophyHistoryPreview:e=>n("trophy:history-import:preview",e),trophyHistoryApply:e=>n("trophy:history-import:apply",e),trophyHistorySnapshots:e=>n("trophy:history-import:snapshots",e||{}),trophyHistorySnapshot:e=>n("trophy:history-import:snapshot",e),trophyProgressionStatus:()=>n("trophy:progression:status"),trophyProgressionCatalogs:()=>n("trophy:progression:catalogs"),trophyProgressionCreate:e=>n("trophy:progression:create",e),trophyProgressionPlans:e=>n("trophy:progression:plans",e||{}),trophyProgressionPlan:e=>n("trophy:progression:plan",e),ecosystemCapabilityGraph:()=>n("ecosystem:capability-graph"),platformBindings:()=>n("platform:bindings:list"),platformBindingsRefresh:()=>n("platform:bindings:refresh"),platformBindingReview:e=>n("platform:bindings:review",e),unifiedAutoloaderList:()=>n("platform:autoloader:list"),unifiedAutoloaderSave:e=>n("platform:autoloader:save",e),unifiedAutoloaderRemove:e=>n("platform:autoloader:remove",e),unifiedAutoloaderPreview:e=>n("platform:autoloader:preview",e),unifiedAutoloaderRecovery:e=>n("platform:autoloader:recovery",e),platformWebsrvDiscover:()=>n("platform:websrv:discover"),remotePlayIntelligence:()=>n("platform:remote-play:intelligence"),shadps4Roots:()=>n("platform:shadps4:roots"),shadps4Select:e=>n("platform:shadps4:select",e),shadps4Scan:()=>n("platform:shadps4:scan"),shadps4Cached:()=>n("platform:shadps4:cached"),shadps4LaunchRoots:()=>n("platform:shadps4:launch-roots"),shadps4LaunchConfigure:e=>n("platform:shadps4:launch-configure",e),shadps4LaunchProbe:()=>n("platform:shadps4:launch-probe"),shadps4LaunchPlan:e=>n("platform:shadps4:launch-plan",e),shadps4Launch:e=>n("platform:shadps4:launch",e),platformArtifactRoots:()=>n("platform:artifacts:roots"),platformArtifacts:()=>n("platform:artifacts:list"),platformArtifactInspect:e=>n("platform:artifacts:inspect",e),platformArtifactReview:e=>n("platform:artifacts:review",e),platformAutoloaderStage:e=>n("platform:artifacts:stage-autoloader",e),platformOperationTypes:()=>n("platform:operations:types"),platformOperationStart:e=>n("platform:operations:start",e),platformOperations:()=>n("platform:operations:list"),platformOperation:e=>n("platform:operations:get",e),platformContracts:()=>n("platform:contracts"),platformNotifications:()=>n("platform:notifications"),platformSearch:e=>n("platform:search",e),ecosystemConsolePayloads:()=>n("ecosystem:console-payloads"),ecosystemPayloadWatchdog:()=>n("ecosystem:payload-runtime:watchdog"),ecosystemPayloadUpdates:()=>n("ecosystem:payload-runtime:updates"),ecosystemPayloadRefresh:()=>n("ecosystem:payload-runtime:refresh"),ecosystemPayloadInstall:e=>n("ecosystem:payload-runtime:install",e),chimeraBeaconStatus:()=>n("chimera-beacon:status"),chimeraBeaconArtifact:()=>n("chimera-beacon:artifact"),chimeraSentinelPair:e=>n("chimera-sentinel:pair",e),ecosystemUpdatePlan:e=>n("ecosystem:updates:plan",{selected:e}),ecosystemUpdatesRefresh:()=>n("ecosystem:updates:refresh"),ecosystemUpdatePrepare:e=>n("ecosystem:updates:prepare",e),ecosystemTrackVersion:(e,t)=>n("ecosystem:updates:track-version",{id:e,version:t}),ecosystemFollowAutomatic:e=>n("ecosystem:updates:follow-automatic",{id:e}),sessionExpertMode:(e,t)=>n("session:expert-mode",{enabled:e,confirmation:t}),ecosystemSupplyPolicy:()=>n("ecosystem:supply-chain:policy"),ecosystemSupplyPolicySave:e=>n("ecosystem:supply-chain:policy-save",e),ecosystemSupplyAudit:()=>n("ecosystem:supply-chain:audit"),ecosystemHealthLatest:()=>n("ecosystem:health:latest"),ecosystemHealthRun:(e=!1)=>n("ecosystem:health:run",{notify:e}),ecosystemHealthHistory:(e=30)=>n("ecosystem:health:history",{limit:e}),ecosystemHealthAcknowledge:e=>n("ecosystem:health:acknowledge",{id:e}),ecosystemHealthOptions:e=>n("ecosystem:health:options",{notificationsEnabled:e}),ecosystemActivity:(e=100)=>n("ecosystem:activity",{limit:e}),ecosystemWebsrvDiscover:()=>n("ecosystem:websrv:discover"),ecosystemWebsrvFiles:(e="/")=>n("ecosystem:websrv:files",{remotePath:e}),ecosystemWebsrvDownload:e=>l(n("ecosystem:websrv:download",{remotePath:e})),ecosystemWebsrvSmb:(e,t="")=>n("ecosystem:websrv:smb",{sourceId:e,smbPath:t}),ecosystemWebsrvSmbDownload:(e,t)=>l(n("ecosystem:websrv:smb-download",{sourceId:e,smbPath:t})),ecosystemLiveSession:()=>n("ecosystem:live-session"),remotePlayStatus:()=>n("remote-play:status"),remotePlayCompanion:()=>n("remote-play:companion"),remotePlayProfileExport:()=>l(n("remote-play:profile-export")),remotePlaySave:e=>n("remote-play:profile-save",e),remotePlayDiscover:()=>n("remote-play:discover"),remotePlayWake:()=>n("remote-play:wake",{confirmation:"WEK PS5"}),remotePlayStart:e=>n("remote-play:start",{...e,confirmation:"START REMOTE PLAY"}),remotePlayWorkerPackage:e=>l(n("remote-play:worker-package",e)),remotePlayWorkerAction:e=>n("remote-play:worker-action",e),remotePlayWorkerRemove:e=>n("remote-play:worker-remove",{workerId:e,confirmation:"VERWIJDER"}),ecosystemEtaHenGet:()=>n("ecosystem:etahen:get"),ecosystemEtaHenPreview:e=>n("ecosystem:etahen:preview",e),ecosystemEtaHenApply:(e,t="")=>n("ecosystem:etahen:apply",{...e,confirmation:t}),ecosystemEtaHenHistory:()=>n("ecosystem:etahen:history"),ecosystemEtaHenRestore:e=>n("ecosystem:etahen:restore",{id:e,confirmation:"HERSTEL"}),ecosystemHomebrewList:()=>n("ecosystem:homebrew:list"),ecosystemHomebrewScan:()=>n("ecosystem:homebrew:scan"),ecosystemHomebrewLaunch:(e,t="")=>n("ecosystem:homebrew:launch",{id:e,confirmation:t}),ecosystemPacbrewCatalog:()=>n("ecosystem:pacbrew:catalog"),ecosystemPacbrewRefresh:()=>n("ecosystem:pacbrew:refresh"),ecosystemPacbrewPackage:e=>n("ecosystem:pacbrew:package",{name:e}),ecosystemLinuxLab:()=>n("ecosystem:linux-lab"),ecosystemSources:()=>n("ecosystem:sources:list"),ecosystemSourceSave:e=>n("ecosystem:sources:save",e),ecosystemSourceRemove:e=>n("ecosystem:sources:remove",{id:e,confirmation:"VERWIJDER"}),ecosystemSourceTest:e=>n("ecosystem:sources:test",{id:e}),ecosystemSourceWebUis:()=>n("ecosystem:sources:webuis"),ezRemoteStatus:()=>n("ezremote:status"),ezRemoteDownloads:()=>n("ezremote:downloads"),ezRemoteDownload:e=>n("ezremote:download",e),consoleProfiles:()=>n("console-profiles:list"),consoleIdentity:()=>n("console:identity"),consoleProfilesCompare:()=>n("console-profiles:comparison"),consoleProfileSave:e=>n("console-profiles:save",e),consoleProfileActivate:e=>n("console-profiles:activate",{id:e}),consoleProfileRemove:e=>n("console-profiles:remove",{id:e,confirmation:"VERWIJDER"}),consoleStorageTopology:()=>n("console:storage-topology"),companionWorkers:()=>n("companion-workers:list"),companionWorkerSave:e=>n("companion-workers:save",e),companionWorkerRemove:e=>n("companion-workers:remove",{id:e,confirmation:"VERWIJDER"}),companionWorkerPair:e=>n("companion-workers:pair",e),companionWorkerCapabilities:e=>n("companion-workers:capabilities",{id:e}),companionWorkerSubmit:e=>n("companion-workers:submit",e),totpStatus:()=>n("auth:totp:status"),totpBegin:()=>n("auth:totp:begin"),totpConfirm:e=>n("auth:totp:confirm",{code:e}),totpDisable:e=>n("auth:totp:disable",{code:e,confirmation:"UITSCHAKELEN"}),genericNotificationStatus:()=>n("notifications:generic:status"),genericNotificationSave:e=>n("notifications:generic:save",e),genericNotificationTest:()=>n("notifications:generic:test"),genericNotificationPreview:e=>n("notifications:generic:preview",{rule:e}),automationSourcesAnalyze:()=>n("automation:sources:analyze"),automationProtectionStatus:()=>n("automation:protection:eligibility"),ecosystemPlugins:()=>n("ecosystem:plugins:list"),ecosystemPluginsScan:()=>n("ecosystem:plugins:scan"),ecosystemPluginInspect:e=>n("ecosystem:plugins:inspect",{id:e}),ecosystemPluginToggle:(e,t,a="")=>n("ecosystem:plugins:toggle",{id:e,enabled:t,confirmation:a}),ecosystemPluginInstall:async(e,t="")=>{const a=await d(".elf,.prx");return a?n("ecosystem:plugins:install",{filename:a.filename,data:a.data,targetRoot:e,confirmation:t}):null},ecosystemCheats:e=>n("ecosystem:cheats:list",e||{}),ecosystemCheatsSync:()=>n("ecosystem:cheats:sync"),ecosystemCheatDeploy:e=>n("ecosystem:cheats:deploy",e),ecosystemRemoteSaves:()=>n("ecosystem:saves:remote"),ecosystemSaveVault:()=>n("ecosystem:saves:list"),ecosystemSaveSnapshot:e=>n("ecosystem:saves:snapshot",{titleId:e}),ecosystemSaveSnapshotAll:()=>n("ecosystem:saves:snapshot-all"),ecosystemSaveDownload:e=>l(n("ecosystem:saves:download",{id:e})),ecosystemSaveDelete:e=>n("ecosystem:saves:delete",{id:e,confirmation:"VERWIJDER"}),ecosystemSaveRestoreCapabilities:()=>n("ecosystem:saves:restore-capabilities"),ecosystemSaveVerify:e=>n("ecosystem:saves:verify",{id:e}),ecosystemSaveRestorePlan:(e,t="")=>n("ecosystem:saves:restore-plan",{id:e,targetUid:t}),ecosystemSaveRestore:(e,t)=>n("ecosystem:saves:restore",{token:e,phrase:t,confirmation:"HERSTEL"}),ecosystemTimeline:(e={})=>n("ecosystem:timeline:status",e),ecosystemTimelineConfigure:e=>n("ecosystem:timeline:configure",e),ecosystemTimelineExport:(e={})=>l(n("ecosystem:timeline:export",e)),ecosystemTimelineStart:e=>n("ecosystem:timeline:start",{port:e}),ecosystemTimelineStop:()=>n("ecosystem:timeline:stop"),ecosystemTimelineImport:()=>n("ecosystem:timeline:import"),ecosystemItemzflowStatus:()=>n("ecosystem:itemzflow:status"),bpgenSdkPairs:()=>n("bpgen:sdk-pairs"),bpgenListMissing:e=>n("bpgen:list-missing",{firmwareLabel:e||""}),bpgenListAll:()=>n("bpgen:list-all-with-local"),bpgenPickFolder:()=>y("Backpork-uitvoermap:","/export"),bpgenRun:e=>n("bpgen:run",e),bpgenCancel:()=>n("bpgen:cancel"),bpgenFakelibScan:()=>n("bpgen:fakelib-scan"),bpgenFakelibSetBasedir:e=>n("bpgen:fakelib-set-basedir",{dir:e||null}),bpgenFakelibPickPup:()=>y("Pad naar het ontsleutelde .PUP.dec-bestand:","/import/PS5UPDATE.PUP.dec"),bpgenFakelibExtract:e=>n("bpgen:fakelib-extract",e),webuiStatus:()=>n("webui:status"),webuiStart:async()=>({ok:!1,...await n("webui:status"),error:"De webserver wordt door de Unraid-container beheerd."}),webuiStop:async()=>({ok:!1,...await n("webui:status"),error:"Stop of herstart Chimera vanuit de Unraid Dockerlijst."}),openDonate:()=>window.open("/donation.html","chimera-donation","popup=yes,width=720,height=760,noopener,noreferrer"),logout:async()=>{await t,await fetch("/api/auth/logout",{method:"POST",credentials:"same-origin",headers:{"X-CSRF-Token":e}}),location.replace("/server-login.html")},systemViewPickSave:()=>Promise.resolve({canceled:!0,filePath:""}),systemViewWriteFile:()=>Promise.resolve({ok:!1}),systemViewAppendFile:()=>Promise.resolve({ok:!1}),systemViewDeleteFile:()=>Promise.resolve({ok:!0}),on:(e,t)=>{a.has(e)||a.set(e,new Set),a.get(e).add(t)},off:e=>a.delete(e)},document.body?.classList.add("web-mode");const f=document.createElement("button");f.type="button",f.id="server-logout",f.textContent="Sign out",f.dataset.i18n="auth.signOut",f.setAttribute("aria-label","Sign out of Chimera server"),f.addEventListener("click",()=>window.pork.logout()),(document.getElementById("sidebar")||document.body)?.appendChild(f)}(); diff --git a/renderer/server-login.html b/renderer/server-login.html new file mode 100644 index 0000000..a0349e6 --- /dev/null +++ b/renderer/server-login.html @@ -0,0 +1,32 @@ + + + + + Chimera — sign in +
    +

    PERSONAL PLAYSTATION PLATFORM

    Chimera

    +

    Sign in securely to your personal Chimera server.

    + + +
    diff --git a/renderer/server-login.js b/renderer/server-login.js new file mode 100644 index 0000000..5fd4507 --- /dev/null +++ b/renderer/server-login.js @@ -0,0 +1,48 @@ +(function () { + 'use strict'; + const loginForm = document.querySelector('#login-form'); + const setupForm = document.querySelector('#setup-form'); + const message = document.querySelector('#message'); + function showMessage(text, kind = '') { message.textContent = text; message.className = kind; } + async function api(url, options = {}) { + const response = await fetch(url, { credentials: 'same-origin', ...options, headers: { 'Content-Type': 'application/json', ...(options.headers || {}) } }); + const data = await response.json(); + if (!response.ok || data.ok === false) throw new Error(data.error || 'Request failed.'); + return data; + } + function values(form) { return Object.fromEntries(new FormData(form).entries()); } + async function initialize() { + const auth = await api('/api/auth/status'); + if (auth.authenticated) { location.replace('/'); return; } + const status = await api('/api/setup/status'); + if (status.configured) { loginForm.hidden = false; const authStatus = await api('/api/auth/status'); document.querySelector('#totp-row').hidden = !authStatus.totpRequired; loginForm.elements.totpCode.required = authStatus.totpRequired; document.querySelector('#intro').textContent = authStatus.totpRequired ? 'Sign in with your password and two-factor authentication.' : 'Sign in to your personal Chimera server.'; return; } + setupForm.hidden = false; setupForm.elements.language.value = status.defaultLanguage || 'en'; document.querySelector('#intro').textContent = 'Complete the one-time secure Docker configuration.'; + document.querySelector('#data-status').textContent = `Data root: ${status.dataRoot} — ${status.volumes.filter(v => v.writable).length}/${status.volumes.length} volumes writable.`; + const roots = document.querySelector('#roots'); + for (const volume of status.volumes) { + const label = document.createElement('label'); label.className = 'check'; + const input = document.createElement('input'); input.type = 'checkbox'; input.name = 'allowedRoots'; input.value = volume.path; input.checked = true; input.disabled = !volume.writable; + label.append(input, document.createTextNode(` ${volume.path} ${volume.writable ? '' : '(read-only)'}`)); roots.append(label); + } + document.querySelector('#capabilities').textContent = Object.entries(status.capabilities.features).map(([name, feature]) => `${name}: ${feature.available ? 'available' : `unavailable — ${feature.reason}`}`).join('\n'); + } + loginForm.addEventListener('submit', async event => { + event.preventDefault(); showMessage('Signing in…'); + try { await api('/api/auth/login', { method: 'POST', body: JSON.stringify(values(loginForm)) }); location.replace('/'); } + catch (error) { showMessage(error.message, 'error'); } + }); + document.querySelector('#test-ftp').addEventListener('click', async () => { + const data = values(setupForm); if (!data.ftpHost) { showMessage('Enter a PS5 host or IP first.', 'error'); return; } + showMessage('Testing FTP connection…'); + try { await api('/api/setup/test-ftp', { method: 'POST', headers: { 'X-Chimera-Bootstrap-Token': data.bootstrapToken || '' }, body: JSON.stringify({ host: data.ftpHost, port: data.ftpPort, user: data.ftpUser, password: data.ftpPassword }) }); showMessage('FTP connection successful.', 'success'); } + catch (error) { showMessage(error.message, 'error'); } + }); + setupForm.addEventListener('submit', async event => { + event.preventDefault(); showMessage('Saving setup securely…'); const data = values(setupForm); + data.allowedRoots = [...setupForm.querySelectorAll('input[name="allowedRoots"]:checked:not(:disabled)')].map(input => input.value); + data.safetyConfirmed = setupForm.elements.safetyConfirmed.checked; + try { await api('/api/setup', { method: 'POST', headers: { 'X-Chimera-Bootstrap-Token': data.bootstrapToken || '' }, body: JSON.stringify(data) }); location.replace('/'); } + catch (error) { showMessage(error.message, 'error'); } + }); + initialize().catch(error => showMessage(error.message, 'error')); +})(); diff --git a/renderer/service-worker.js b/renderer/service-worker.js new file mode 100644 index 0000000..f1df12e --- /dev/null +++ b/renderer/service-worker.js @@ -0,0 +1,39 @@ +'use strict'; + +const CACHE = 'chimera-shell-v1'; +const SHELL = ['/offline.html', '/manifest.webmanifest', '/build/icon.png']; + +self.addEventListener('install', event => { + event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(SHELL)).then(() => self.skipWaiting())); +}); + +self.addEventListener('activate', event => { + event.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(key => key.startsWith('chimera-shell-') && key !== CACHE).map(key => caches.delete(key)))).then(() => self.clients.claim())); +}); + +function cacheable(url) { + return url.origin === self.location.origin && (url.pathname === '/manifest.webmanifest' || url.pathname === '/offline.html' || url.pathname === '/build/icon.png' || url.pathname.startsWith('/styles/') || url.pathname.startsWith('/modules/')); +} + +self.addEventListener('fetch', event => { + if (event.request.method !== 'GET') return; + const url = new URL(event.request.url); + if (event.request.mode === 'navigate') { + event.respondWith(fetch(event.request).catch(() => caches.match('/offline.html'))); + return; + } + if (!cacheable(url)) return; + const isIcon = url.pathname === '/build/icon.png'; + const cacheFallback = isIcon ? '/build/icon.png' : event.request; + event.respondWith( + fetch(event.request).then(response => { + if (response.ok && response.type === 'basic') { + caches.open(CACHE).then(cache => { + cache.put(event.request, response.clone()); + if (isIcon) cache.put(cacheFallback, response.clone()); + }); + } + return response; + }).catch(() => caches.match(cacheFallback)), + ); +}); diff --git a/renderer/splash.html b/renderer/splash.html new file mode 100644 index 0000000..0f474f5 --- /dev/null +++ b/renderer/splash.html @@ -0,0 +1,84 @@ + + + + + + + + Starting Chimera + + + + +

    Chimera

    +

    PS operations hub

    +
    +

    Starting up…

    + + + + diff --git a/renderer/styles/base.css b/renderer/styles/base.css new file mode 100644 index 0000000..a848ce4 --- /dev/null +++ b/renderer/styles/base.css @@ -0,0 +1 @@ +:root{--sidebar-w:210px;--titlebar-h:32px;--statusbar-h:26px}*,::after,::before{box-sizing:border-box;margin:0;padding:0}[hidden]{display:none!important}body,html{height:100%;background:var(--bg);color:var(--text);font-family:-apple-system,'Segoe UI',system-ui,sans-serif;font-size:14px;overflow:hidden;user-select:none}#titlebar{position:fixed;top:0;left:0;right:0;height:var(--titlebar-h);background:#0d0d0d;border-bottom:1px solid #000;display:flex;align-items:center;justify-content:space-between;z-index:2000;-webkit-app-region:drag}#titlebar-drag{display:flex;align-items:center;gap:8px;padding-left:12px;font-size:12px;font-weight:600;color:var(--text-dim)}#mobile-nav-toggle{display:none;width:34px;height:34px;border:none;background:0 0;color:var(--text);font-size:20px;border-radius:8px;cursor:pointer;-webkit-app-region:no-drag}#mobile-nav-toggle:hover{background:rgba(255,255,255,.08)}.titlebar-icon{width:18px;height:18px}.titlebar-by{font-size:11px;font-weight:400;color:var(--text-dim);opacity:.5;letter-spacing:.04em;pointer-events:none}#titlebar-x-link{display:inline-flex;align-items:center;gap:5px;color:var(--text-dim);font-size:11px;text-decoration:none;padding:3px 10px;border-radius:16px;border:1px solid var(--border);transition:color .15s,border-color .15s;-webkit-app-region:no-drag;flex-shrink:0;margin:0 auto}#titlebar-x-link:hover{color:var(--text);border-color:var(--text-dim)}#titlebar-x-link svg{flex-shrink:0}#titlebar-controls{display:flex;-webkit-app-region:no-drag}#titlebar-controls button{width:44px;height:32px;background:0 0;border:none;color:var(--text-dim);font-size:12px;cursor:pointer;transition:background .15s,color .15s}#titlebar-controls button:hover{background:rgba(255,255,255,.1);color:var(--text)}#btn-cls:hover{background:var(--red)!important;color:#fff!important}body.web-mode #titlebar{-webkit-app-region:no-drag;justify-content:flex-start}body.web-mode #titlebar-controls,body.web-mode #titlebar-x-link{display:none}body.web-mode #titlebar-drag{width:100%;padding-right:12px}#app{position:fixed;top:var(--titlebar-h);left:0;right:0;bottom:var(--statusbar-h);display:flex}#sidebar{width:var(--sidebar-w);flex-shrink:0;background:#0d0d0d;border-right:1px solid #000;display:flex;flex-direction:column;padding:16px 0}#sidebar-scrim{display:none;position:fixed;top:var(--titlebar-h);left:0;right:0;bottom:var(--statusbar-h);background:rgba(0,0,0,.5);z-index:1840}.sidebar-brand{display:flex;align-items:center;gap:8px;padding:4px 16px 20px;font-size:16px;font-weight:700;color:var(--accent)}.brand-icon{width:26px;height:26px;flex-shrink:0}.nav-list{list-style:none;flex:1}.nav-link{display:flex;align-items:center;gap:10px;padding:10px 16px;color:var(--text-dim);text-decoration:none;font-size:12px;cursor:pointer;transition:.15s;border-left:3px solid transparent}.nav-link svg{width:17px;height:17px;flex-shrink:0}.nav-link:hover{color:var(--text);background:rgba(255,255,255,.05)}.nav-link.active{color:var(--accent);border-left-color:var(--accent);background:rgba(var(--accent-rgb),.08)}#sidebar-donate{display:flex;flex-direction:column;align-items:center;gap:5px;padding:10px 16px 8px;background:0 0;border:none;border-top:1px solid var(--border);cursor:pointer;width:100%;transition:background .15s;-webkit-app-region:no-drag}#sidebar-donate:hover{background:rgba(187,134,252,.06)}#sidebar-donate:active{background:rgba(187,134,252,.12)}.donate-icon{width:30px;height:30px;border-radius:8px;filter:drop-shadow(0 0 6px rgba(187,134,252,.4));transition:filter .2s}#sidebar-donate:hover .donate-icon{filter:drop-shadow(0 0 10px rgba(187,134,252,.7))}.donate-label{font-size:11px;font-weight:600;color:var(--accent);letter-spacing:.06em;text-transform:uppercase}#sidebar-conn{display:flex;align-items:center;gap:8px;padding:12px 16px;font-size:12px;color:var(--text-dim);border-top:1px solid var(--border)}#server-logout{margin:0 12px;padding:7px 10px;color:var(--text-dim);background:rgba(255,255,255,.025);border:1px solid var(--border);border-radius:8px;font:inherit;font-size:11px;cursor:pointer}#server-logout:focus-visible,#server-logout:hover{color:var(--text);border-color:rgba(var(--accent-rgb),.45)}.dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}.dot.red{background:var(--red)}.dot.green{background:var(--green)}#content{flex:1;min-width:0;overflow-y:auto;padding:24px;display:flex;flex-direction:column;gap:20px}#content::-webkit-scrollbar{width:6px}#content::-webkit-scrollbar-track{background:0 0}#content::-webkit-scrollbar-thumb{background:var(--border);border-radius:8px}.page{display:none;flex-direction:column;gap:20px}.page.active{display:flex}.page-header{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px}.page-header h1{font-size:22px;font-weight:600;color:var(--text)}.header-tools{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.stats-row{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px}.stat-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:20px;text-align:center}.stat-num{font-size:36px;font-weight:700;color:var(--accent);line-height:1}.stat-lbl{font-size:12px;color:var(--text-dim);margin-top:6px}.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:20px;display:flex;flex-direction:column;gap:14px}.card-header{display:flex;align-items:center;flex-wrap:wrap;gap:12px}.card-header h3{font-size:16px;font-weight:600}.btn{padding:7px 16px;border-radius:8px;border:1.5px solid var(--border);background:var(--surface2);color:var(--text);font-size:12px;font-weight:500;cursor:pointer;transition:.15s;white-space:nowrap}.btn:hover:not(:disabled){border-color:var(--accent);color:var(--accent)}.btn:disabled{opacity:.4;cursor:not-allowed}.btn-accent{background:var(--accent);color:var(--bg);border-color:var(--accent);font-weight:600}.btn-accent:hover:not(:disabled){background:var(--accent-d);border-color:var(--accent-d);color:var(--bg)}.btn-teal{background:var(--teal);color:var(--bg);border-color:var(--teal);font-weight:600}.btn-teal:hover:not(:disabled){background:var(--teal-d);border-color:var(--teal-d);color:#fff}.btn-danger{border-color:var(--red);color:var(--red)}.btn-danger:hover:not(:disabled){background:var(--red);color:#fff}.btn-sm{padding:4px 10px;font-size:12px}.btn-row{display:flex;gap:10px;flex-wrap:wrap;align-items:center}.form-row{display:flex;gap:12px;flex-wrap:wrap;align-items:flex-end}.form-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px}.ftp-ports-section{margin-top:14px;display:flex;flex-direction:column;gap:6px}.ftp-ports-label{font-size:12px;color:var(--text-dim)}.ftp-ports-presets{display:flex;gap:6px;flex-wrap:wrap}.ftp-ports-section>input{width:100%}label{display:flex;flex-direction:column;gap:5px;font-size:12px;color:var(--text-dim)}input,select,textarea{user-select:text;background:var(--bg);border:1.5px solid var(--border);border-radius:8px;color:var(--text);font-size:12px;padding:7px 10px;outline:0;transition:border-color .2s}input:focus,textarea:focus{border-color:var(--accent)}input::placeholder,textarea::placeholder{color:var(--text-dim)}.search-input{padding:7px 12px;border-radius:16px;min-width:220px}.filter-select{padding:7px 10px;border-radius:16px;background:var(--surface2);border:1px solid var(--border);color:var(--text);font-size:12px;cursor:pointer}.hint{font-size:12px;color:var(--text-dim)}.toggle-row{display:flex;flex-direction:row;flex-wrap:wrap;align-items:center;gap:8px;margin-top:12px;cursor:pointer}.toggle-row input[type=checkbox]{width:15px;height:15px;flex-shrink:0;cursor:pointer;accent-color:var(--accent);padding:0;border:none;background:0 0}.toggle-label{font-size:12px;color:var(--text)}label.toggle-label{flex-direction:row}.toggle-hint{flex-basis:100%;margin-left:23px}.filter-btns{display:flex;gap:6px}.fbtn{padding:5px 12px;border-radius:16px;border:1.5px solid var(--border);background:0 0;color:var(--text-dim);font-size:12px;cursor:pointer;transition:.15s}.fbtn:hover{border-color:var(--accent);color:var(--text)}.fbtn.active{background:var(--accent);border-color:var(--accent);color:var(--bg);font-weight:600}.media-sort-select{margin-left:auto;padding:4px 10px;border-radius:16px;border:1.5px solid var(--border);background:var(--surface2);color:var(--text-dim);font-size:12px;cursor:pointer;outline:0;transition:border-color .15s}.media-sort-select:hover{border-color:var(--accent);color:var(--text)}.filter-bar{display:flex;gap:6px;align-items:center}.badge{padding:2px 8px;border-radius:12px;font-size:11px;font-weight:600}.badge-red{background:rgba(207,102,121,.2);color:var(--red)}.badge-green{background:rgba(76,175,80,.2);color:var(--green)}.table-wrap{overflow-x:auto;border-radius:var(--radius);border:1px solid var(--border)}.table-wrap::-webkit-scrollbar{height:4px}.table-wrap::-webkit-scrollbar-thumb{background:var(--border);border-radius:8px}table{width:100%;border-collapse:collapse}thead th{background:var(--surface2);padding:10px 12px;text-align:left;font-size:12px;font-weight:600;color:var(--text-dim);cursor:pointer;white-space:nowrap;position:sticky;top:0;z-index:1;border-bottom:1px solid var(--border)}thead th:hover{color:var(--accent)}thead th:last-child{cursor:default}.sort-icon{opacity:.4}tbody tr{border-bottom:1px solid #1a1a1a;transition:background .1s}tbody tr:hover{background:var(--surface2)}tbody tr:last-child{border-bottom:none}tbody td{padding:9px 12px;font-size:12px;white-space:nowrap}tr.game-row{cursor:pointer}tr.game-row:hover td{background:var(--surface3)}.game-title-cell{display:flex;align-items:center;gap:10px;white-space:nowrap}.game-title-copy{display:flex;flex-direction:column;min-width:0;line-height:1.25}.game-title-copy strong{font-weight:650;color:var(--text)}.game-title-copy small{max-width:420px;overflow:hidden;text-overflow:ellipsis;color:var(--text-dim);font-size:11px}.game-actions-cell{white-space:nowrap}.game-status-col{display:flex;align-items:center;gap:4px;flex-wrap:wrap}.s-tag{font-size:11px;padding:1px 5px;border-radius:8px;font-weight:600;white-space:nowrap}.s-platform-ps4{background:rgba(58,123,213,.18);color:#76a9ff}.s-platform-ps5{background:rgba(0,206,209,.15);color:var(--teal)}.s-platform-unknown{background:rgba(255,255,255,.07);color:var(--text-dim)}.s-installed{background:rgba(76,175,80,.15);color:#66cf6b}.s-num{background:rgba(255,255,255,.07);color:var(--text-dim)}.s-backed{background:rgba(76,175,80,.15);color:#4caf50}.s-warn{background:rgba(255,152,0,.18);color:#ff9800}.game-row-patches-only td{opacity:.72}.game-row-patches-only:hover td{opacity:1}.patches-only-hint{font-size:11px;color:#ff9800;white-space:nowrap;display:inline-flex;align-items:center;gap:4px}.game-row-icon{width:36px;height:36px;border-radius:8px;object-fit:cover;flex-shrink:0}.game-row-icon-empty{width:36px;height:36px;border-radius:8px;flex-shrink:0;background:var(--surface2);border:1px solid var(--border)}.pill{display:inline-block;padding:2px 8px;border-radius:12px;font-size:11px;font-weight:600}.pill-yes{background:rgba(76,175,80,.2);color:var(--green)}.pill-no{background:rgba(158,158,158,.15);color:var(--text-dim)}.pill-num{background:rgba(var(--accent-rgb),.15);color:var(--accent)}.empty-msg{padding:50px;text-align:center;color:var(--text-dim);font-size:14px}#backups-list{display:flex;flex-direction:column;gap:8px;padding:0 0 20px}.backup-card{display:flex;align-items:center;gap:14px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:12px 16px;cursor:pointer;transition:border-color .15s}.backup-card:hover{border-color:var(--accent)}.backup-card-icon{width:52px;height:52px;border-radius:8px;object-fit:cover;flex-shrink:0;box-shadow:0 2px 8px rgba(0,0,0,.5)}.backup-card-icon-empty{width:52px;height:52px;border-radius:8px;flex-shrink:0;background:var(--surface2);border:1px solid var(--border)}.backup-card-body{flex:1;display:flex;flex-direction:column;gap:4px;min-width:0}.backup-card-title{font-weight:600;font-size:14px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}.backup-card-id{font-size:12px;color:var(--text-dim)}.backup-card-badges{display:flex;gap:6px;flex-wrap:wrap;margin-top:2px}.pill-region{background:rgba(255,255,255,.08);color:var(--text-dim);font-size:11px}.bk-badge{font-size:11px;padding:2px 8px;border-radius:8px;font-weight:600}.bk-badge-local{background:rgba(var(--accent-rgb),.15);color:var(--accent)}.bk-badge-backpork{background:rgba(3,218,198,.12);color:var(--teal)}.bk-badge-dlc,.bk-badge-patch{background:rgba(255,255,255,.07);color:var(--text-dim)}.backup-card-view{flex-shrink:0}.db-tabs{display:flex;gap:4px;border-bottom:1px solid var(--border);padding-bottom:0}.db-tab{padding:8px 18px;background:0 0;border:none;border-bottom:2px solid transparent;color:var(--text-dim);font-size:12px;cursor:pointer;transition:.15s}.db-tab:hover{color:var(--text)}.db-tab.active{color:var(--accent);border-bottom-color:var(--accent)}.db-panel{display:none}.db-panel.active{display:block}.query-area{display:flex;gap:10px;align-items:flex-start;margin-bottom:16px}#query-input{flex:1;height:80px;resize:vertical;font-family:Consolas,monospace;font-size:12px}#query-results table{background:var(--surface)}#query-results .empty-msg{padding:20px}.progress-area{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:10px 14px;font-size:12px;color:var(--text-dim);font-family:monospace;max-height:120px;overflow-y:auto}#toast-container{position:fixed;bottom:calc(var(--statusbar-h) + 10px);right:14px;display:flex;flex-direction:column-reverse;gap:8px;z-index:9000;pointer-events:none}.toast{display:flex;align-items:flex-start;gap:10px;min-width:260px;max-width:380px;background:var(--surface2);border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:8px;padding:10px 12px;font-size:12px;color:var(--text);box-shadow:0 4px 20px rgba(0,0,0,.6);pointer-events:auto;animation:.22s cubic-bezier(.2,.8,.4,1) both toast-in}.toast.toast-ok{border-left-color:var(--green)}.toast.toast-error{border-left-color:var(--red)}.toast.toast-info{border-left-color:var(--accent)}.toast.toast-out{animation:.25s forwards toast-out}.toast-icon{font-size:14px;line-height:1;flex-shrink:0;margin-top:1px}.toast-body{flex:1;line-height:1.4}.toast-close{flex-shrink:0;background:0 0;border:none;cursor:pointer;color:var(--text-dim);font-size:16px;line-height:1;padding:0 0 0 4px;margin-top:-1px;transition:color .15s}.toast-close:hover{color:var(--text)}.toast-count{flex:none;min-width:22px;padding:1px 6px;color:var(--accent);font:700 11px/1.5 var(--mono);text-align:center;background:rgba(var(--accent-rgb),.14);border-radius:12px}body.chimera-dialog-open #content{overflow:hidden}@keyframes toast-in{from{opacity:0;transform:translateX(24px)}to{opacity:1;transform:translateX(0)}}@keyframes toast-out{from{opacity:1;transform:translateX(0);max-height:80px;margin-bottom:0}to{opacity:0;transform:translateX(24px);max-height:0;margin-bottom:-8px}}#statusbar{position:fixed;bottom:0;left:0;right:0;height:var(--statusbar-h);background:#0d0d0d;border-top:1px solid #000;display:flex;align-items:center;gap:20px;padding:0 14px;font-size:12px;color:var(--text-dim);z-index:1000}#sb-msg{margin-left:auto;color:var(--yellow)}@media (max-width:900px){:root{--sidebar-w:min(320px,84vw)}#mobile-nav-toggle{display:inline-flex;align-items:center;justify-content:center}#titlebar{padding:0 8px;gap:8px}#titlebar-drag{padding-left:0;min-width:0}#titlebar-x-link,.titlebar-by{display:none}#titlebar-controls button{width:38px}#sidebar{position:fixed;top:var(--titlebar-h);bottom:var(--statusbar-h);left:0;width:var(--sidebar-w);z-index:1850;transform:translateX(-105%);transition:transform .22s;box-shadow:20px 0 40px rgba(0,0,0,.45)}body.mobile-nav-open #sidebar{transform:translateX(0)}body.mobile-nav-open #sidebar-scrim{display:block!important}#content{padding:14px;min-width:0}.page{gap:14px}.page-header h1{font-size:18px}.header-tools{width:100%}.search-input{min-width:0;width:100%}.stats-row{grid-template-columns:repeat(2,minmax(0,1fr))}.card{padding:14px}.form-row{flex-direction:column;align-items:stretch}.btn-row,.filter-btns{flex-wrap:wrap}.nav-link{font-size:16px;padding:12px 16px}#statusbar{gap:10px;padding:0 10px;font-size:11px}#sb-msg{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}}@media (max-width:560px){#content{padding:12px}.stats-row{grid-template-columns:1fr}.sidebar-brand{padding-bottom:12px}#sidebar-conn{font-size:11px}.page-header{gap:8px}}:where(a,button,input,select,textarea,[tabindex]):focus-visible{outline:2px solid var(--accent);outline-offset:2px}@media (max-width:700px){:where(button,.btn,.nav-link,input,select){min-height:44px}}@media (prefers-reduced-motion:reduce){*,::after,::before{scroll-behavior:auto!important;animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important}} diff --git a/renderer/styles/chimera-system.css b/renderer/styles/chimera-system.css new file mode 100644 index 0000000..801a462 --- /dev/null +++ b/renderer/styles/chimera-system.css @@ -0,0 +1,442 @@ +/* Chimera design system — corrections layer. + Loaded last on purpose: several component stylesheets are linked after + stitch.css, so token-level fixes have to sit downstream of all of them. + Tokens still live in stitch.css; this file only resolves conflicts. */ +/* ============================================================================ + Design-system corrections — 2026-08-23 + One authoritative block. stitch.css owns the tokens; base.css owns the reset. + Ordered: type scale, elevation, surfaces, controls, forms, edges. + ========================================================================== */ + +/* --- Type: one scale, sentence case above the label sizes ----------------- */ +#chimera-route-title{ + font-family:var(--display); + font-size:var(--fs-display); + font-weight:640; + letter-spacing:-.03em; + line-height:1.04; + text-transform:none; + text-wrap:balance; +} +.stat-num{font-family:var(--display);font-size:var(--fs-xl);font-weight:600;line-height:1;font-variant-numeric:tabular-nums} +.stat-lbl{font-size:var(--fs-xs)} +.page-header h1{font-family:var(--display);font-size:var(--fs-xl);font-weight:620;letter-spacing:-.025em;text-transform:none} +h2{font-size:var(--fs-lg)} +h3,h4{font-size:var(--fs-md)} + +.game-card .s-tag{font-size:var(--fs-micro)} +#chimera-route-breadcrumb{font-size:var(--fs-micro);letter-spacing:.14em} + +/* --- Elevation: three levels, nothing invented per component -------------- */ +.card,.stat-card,.table-wrap,.chimera-home-panel{box-shadow:var(--shadow-panel)} +.modal,.modal-shell,.import-wizard,#toast-container .toast,.chimera-command-palette{box-shadow:var(--shadow-float)} +.btn,.chimera-toolbar-button,.chimera-route-link,.fbtn,.badge,.pill{box-shadow:var(--shadow-flat)} + +/* --- Surfaces: inherit the radius scale, stop hand-rolling values --------- */ +.card,.stat-card,.table-wrap,.backup-card,.progress-area,.chimera-home-panel,.catalog-health-strip{border-radius:var(--radius)} +.btn,.fbtn,input,select,textarea,.filter-select,.search-input, +.chimera-domain-button,.modal-workspace-links button{border-radius:var(--radius-sm)} +.modal,.modal-shell,.import-wizard{border-radius:var(--radius-lg)} +.badge,.pill,.s-tag,.bk-badge{border-radius:999px} + +/* --- Controls: one filled button per view --------------------------------- */ +/* Teal was acting as a second primary. It becomes an outlined action and the + accent fill stays the single call to action. */ +.btn-teal{ + background:transparent; + color:var(--teal); + border-color:rgba(34,199,232,.42); + font-weight:600; +} +.btn-teal:hover:not(:disabled){ + background:rgba(34,199,232,.1); + border-color:var(--teal); + color:var(--teal); +} +.btn-accent{background:var(--accent-d);border-color:var(--accent-d);color:#fff} +.btn-accent:hover:not(:disabled){background:var(--accent);border-color:var(--accent);color:#1a1220} +.btn{min-height:36px;font-size:var(--fs-xs);display:inline-flex;align-items:center;justify-content:center;gap:7px} + +/* --- Forms: label above field, one control height, full card width -------- */ +.card .form-row,.card .form-grid{width:100%} +.card label{flex-direction:column;align-items:flex-start;gap:6px;font-size:var(--fs-xs)} +.card label.toggle-label,.card .toggle-row{flex-direction:row;align-items:center} +input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=color]), +select,textarea{min-height:40px;font-size:var(--fs-xs)} + +/* The accent picker was a raw native colour input sitting outside the system. */ +.color-swatch{ + position:relative;width:40px;height:40px;flex:none;overflow:hidden; + border:1px solid var(--border);border-radius:var(--radius-sm); + background:var(--accent-d); +} +.color-picker-input{ + position:absolute;inset:-4px;width:calc(100% + 8px);height:calc(100% + 8px); + padding:0;border:0;background:transparent;cursor:pointer;opacity:0; +} +.color-hex-label{ + width:11ch;font-family:var(--mono);font-size:var(--fs-xs); + text-transform:uppercase;text-align:center; +} + +/* --- Edges: error placement, toast clearance, orphaned rails -------------- */ +/* The header banner and the toast reported the same failure twice. The banner + keeps it (state persists); the toast is suppressed for route-load errors. */ +.chimera-route-availability{ + max-width:52ch;padding:7px 11px;line-height:1.45; + background:rgba(228,173,85,.1);border:1px solid rgba(228,173,85,.3); + color:var(--yellow);white-space:normal; +} + +/* Toast stack clears the floating import button instead of landing under it. */ +#toast-container{bottom:calc(var(--statusbar-h) + 74px)} + +/* The catalog health rail was floating in the grid with no container, and its + text values were rendered at the numeral display size. */ +.catalog-health-strip{ + background:var(--surface2); + border:1px solid var(--border-soft); + padding:6px 4px; +} +#game-catalog-health{font-size:var(--fs-xs)} +#game-catalog-health strong,#game-catalog-health b{ + font-family:var(--display);font-size:var(--fs-lg);font-weight:600; + line-height:1.15;font-variant-numeric:tabular-nums; +} + + +/* Compact settings rows: a class, not 17 copies of the same inline style. */ +.setting-label{ + flex:none; + white-space:nowrap; + font-size:var(--fs-xs); + color:var(--text-dim); +} +.form-row>.setting-label,.color-picker-row>.setting-label{align-self:center} + +/* Last two off-scale sizes: a button inheriting the UA default, and a glyph. */ +#sidebar-donate{font-size:var(--fs-xs)} +.chimera-command-search-icon,.game-library-search>span[aria-hidden]{font-size:var(--fs-md);line-height:1} + +/* ============================================================================ + Library: poster-first cards + The grid is the artwork. Actions live on the art and only when reached; + the card itself opens the detail modal, so it carries no button stack. + ========================================================================== */ + +#games-grid{ + grid-template-columns:repeat(auto-fill,minmax(170px,1fr)); + gap:20px 18px; + align-items:start; +} + +.game-card{ + display:flex;flex-direction:column;gap:0; + background:0 0;border:0;box-shadow:none;padding:0; + overflow:visible; +} + +/* --- the poster ---------------------------------------------------------- */ +.game-card-art{ + position:relative; + aspect-ratio:var(--game-cover-ratio, 2 / 3); + overflow:hidden; + border-radius:var(--radius); + background:var(--surface2); + border:1px solid var(--border-soft); + box-shadow:0 12px 28px -14px rgba(0,0,0,.75); + transition:box-shadow .24s cubic-bezier(.2,.8,.2,1),border-color .24s; +} +.game-card:hover .game-card-art, +.game-card:focus-visible .game-card-art{ + border-color:rgba(var(--accent-rgb),.5); + box-shadow:0 22px 46px -16px rgba(0,0,0,.85),0 0 0 1px rgba(var(--accent-rgb),.16); +} +.game-card-cover{width:100%;height:100%;object-fit:cover;display:block} + +/* Coverless titles get a mark derived from their own title ID, so a grid with + no artwork still reads as a shelf of distinct things. */ +.game-card-cover-fallback.is-visible{ + position:absolute;inset:0;aspect-ratio:auto; + padding:14px;gap:4px; + background: + radial-gradient(circle at 74% 16%, hsl(var(--card-hue,265) 70% 46% / .42), transparent 46%), + radial-gradient(circle at 18% 88%, hsl(calc(var(--card-hue,265) + 46) 68% 40% / .34), transparent 50%), + linear-gradient(158deg,#1a1422,#0a0b11); +} +.game-card-cover-fallback.is-visible::before{ + top:14%;right:-22%;width:86%; + border-color:hsl(var(--card-hue,265) 80% 78% / .22); +} +.game-card-cover-fallback strong{font-size:var(--fs-md);max-width:13ch;line-height:1.12} +.game-card-cover-fallback span{color:hsl(var(--card-hue,265) 85% 80% / .9)} + +/* --- state marker: a corner chip, not a banner across the poster ---------- */ +.game-card-sync{ + position:absolute;top:10px;left:10px;right:auto; + display:inline-flex;align-items:center;gap:6px; + max-width:calc(100% - 20px); + padding:4px 9px; + font-size:var(--fs-micro);font-weight:600;letter-spacing:.02em; + color:var(--text);background:rgba(10,8,14,.72); + border:1px solid var(--border-soft);border-radius:999px; + backdrop-filter:blur(10px); + overflow:hidden;text-overflow:ellipsis;white-space:nowrap; +} +.game-card-sync.pending::before{ + content:"";flex:none;width:6px;height:6px;border-radius:50%; + background:var(--yellow);box-shadow:0 0 0 3px rgba(228,173,85,.18); +} + +/* --- actions: on the art, on reach --------------------------------------- */ +.game-card-actions{ + position:absolute;left:0;right:0;bottom:0; + display:flex;gap:6px;padding:10px; + background:linear-gradient(transparent,rgba(8,6,12,.82) 42%); + opacity:0;transform:translateY(6px); + z-index:4; + transition:opacity .2s,transform .2s cubic-bezier(.2,.8,.2,1); +} +.game-card:hover .game-card-actions, +.game-card:focus-within .game-card-actions{opacity:1;transform:none} +.game-card-actions .btn{flex:1;min-height:32px;padding-inline:8px;font-size:var(--fs-micro)} + +/* Pointer-less and reduced-motion contexts never get hover, so pin them open. */ +@media (hover:none){ + .game-card-actions{position:static;opacity:1;transform:none;background:0 0;padding:8px 0 0} +} +@media (prefers-reduced-motion:reduce){ + .game-card-art,.game-card-actions{transition:none} +} + +/* --- caption ------------------------------------------------------------- */ +.game-card-body{ + display:flex;flex-direction:column;gap:3px; + padding:10px 2px 0;background:0 0;border:0; +} +.game-card-heading strong{ + display:block;font-size:var(--fs-xs);font-weight:600;line-height:1.3;color:var(--text); + overflow:hidden;text-overflow:ellipsis;white-space:nowrap; +} +.game-card-meta{ + font-family:var(--mono);font-size:var(--fs-micro);color:var(--text-muted); + overflow:hidden;text-overflow:ellipsis;white-space:nowrap; +} +.game-card .game-card-status{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px} + +/* The sync chip was a full-width purple banner in uppercase mono across the + poster. It is a state marker, not a headline. */ +.game-card-sync, +.game-card-sync.pending{ + color:var(--text); + background:rgba(10,8,14,.72); + border-color:var(--border-soft); + font:600 var(--fs-micro)/1.2 var(--ui); + letter-spacing:.01em; + text-transform:none; + right:auto; +} + +/* A coverless poster carries the title as its artwork, so the caption below it + would only repeat itself. Show the caption title when real cover art exists. */ +/* Two art treatments already print the title onto the poster: the generated + fallback, and the "case" used when only a square icon exists. In both the + caption below would just repeat it. */ +.game-card:has(.game-card-cover-fallback.is-visible) .game-card-heading, +.game-card:has(.game-card-cover-caption) .game-card-heading{display:none} +.game-card:has(.game-card-cover-fallback.is-visible) .game-card-body, +.game-card:has(.game-card-cover-caption) .game-card-body{padding-top:8px} + +/* ============================================================================ + Title detail: hero header + The dialog opened on a fifteen-row key/value table with an empty banner bar. + The header now carries the artwork, the name and the identity chips. + ========================================================================== */ + +#modal-header{border-bottom:0} +#modal-banner{ + filter:blur(18px) saturate(1.15); + transform:scale(1.25); + opacity:.55; +} +/* No artwork: a ground derived from the title's own ID, same seed as the card. */ +#modal-header::before{ + content:"";position:absolute;inset:0; + background: + radial-gradient(circle at 78% 8%, hsl(var(--modal-hue,265) 72% 48% / .40), transparent 52%), + radial-gradient(circle at 12% 96%, hsl(calc(var(--modal-hue,265) + 46) 66% 42% / .30), transparent 55%), + linear-gradient(160deg,#181322,#0b0910); +} +#modal-banner.has-art{opacity:.78} +/* Scrim so the title always holds against whatever artwork loads. */ +#modal-header::after{ + content:"";position:absolute;inset:0;pointer-events:none; + background:linear-gradient(180deg,rgba(10,8,14,.30),rgba(18,14,23,.86) 72%,var(--surface)); +} +#modal-header-content{position:relative;z-index:2} + +#modal-icon{ + width:84px!important;height:84px!important; + border-radius:var(--radius-sm)!important; + border:1px solid rgba(255,255,255,.14); + box-shadow:0 16px 34px -12px rgba(0,0,0,.9)!important; +} +#modal-header h2#modal-title{text-wrap:balance} +#modal-subtitle{ + margin-top:6px; + font-family:var(--mono); + font-size:var(--fs-micro); + color:var(--text-dim); + letter-spacing:.04em; +} +#modal-close{ + position:relative;z-index:3; + width:36px;height:36px;display:grid;place-items:center; + border-radius:50%; + background:rgba(10,8,14,.55);border:1px solid var(--border-soft); + color:var(--text);backdrop-filter:blur(10px); +} +#modal-close:hover{background:rgba(10,8,14,.8);border-color:var(--accent)} + +/* Workspace links read as destinations, not as a second toolbar. */ +.modal-workspace-links{margin-top:12px;gap:6px} +.modal-workspace-links button{ + background:rgba(10,8,14,.5)!important; + border-color:var(--border-soft)!important; + backdrop-filter:blur(10px); +} + +/* --- metric tiles --------------------------------------------------------- */ +.modal-metrics{ + display:grid;grid-template-columns:repeat(auto-fit,minmax(132px,1fr)); + gap:1px;background:var(--border-soft); + border:1px solid var(--border-soft);border-radius:var(--radius); + overflow:hidden;margin-bottom:18px; +} +.modal-metric{ + display:flex;flex-direction:column;gap:5px; + padding:14px 16px;background:var(--surface2); +} +.modal-metric span{ + font-family:var(--mono);font-size:var(--fs-micro); + letter-spacing:.11em;text-transform:uppercase;color:var(--text-muted); +} +.modal-metric strong{ + font-family:var(--display);font-size:var(--fs-md);font-weight:600; + color:var(--text);font-variant-numeric:tabular-nums;line-height:1.15; +} +.modal-metric.is-ok strong{color:var(--green)} +.modal-metric.is-warn strong{color:var(--yellow)} +.modal-metric.is-idle strong{color:var(--text-muted)} + +/* --- tab strip: fade the overflow instead of showing a raw scrollbar ------- */ +.modal-tabs{ + position:relative; + scrollbar-width:none; + -webkit-mask-image:linear-gradient(90deg,#000 0,#000 calc(100% - 44px),transparent); + mask-image:linear-gradient(90deg,#000 0,#000 calc(100% - 44px),transparent); +} +.modal-tabs::-webkit-scrollbar{height:0} + +/* --- metadata table: let absent values recede ----------------------------- */ +.detail-table th{ + font-family:var(--ui);font-size:var(--fs-xs);font-weight:500; + letter-spacing:0;text-transform:none;color:var(--text-muted); +} +.detail-table td{font-size:var(--fs-xs)} +.detail-table td{color:var(--text)} + +/* ============================================================================ + Title detail: subsystem rail + Thirteen subsystems sat in a horizontal tab strip that overflowed and had to + be scrolled to be read. The same buttons become a vertical rail — every + destination scannable at once, no horizontal scroll, and room for the summary + to breathe. Structure is untouched: #modal already has exactly four children, + so this is a grid re-layout, not a rebuild. + Below 760px the original horizontal strip is kept; a rail costs too much width. + ========================================================================== */ +@media (min-width:760px){ + #modal{ + display:grid; + grid-template-columns:206px minmax(0,1fr); + grid-template-rows:auto minmax(0,1fr) auto; + } + #modal-header{grid-column:1/-1;grid-row:1} + #modal-body{grid-column:2;grid-row:2;min-height:0;overflow-y:auto} + #modal-footer{grid-column:1/-1;grid-row:3} + + .modal-tabs{ + grid-column:1;grid-row:2; + display:flex;flex-direction:column;align-items:stretch; + gap:2px; + min-height:0; + padding:14px 10px 18px; + overflow-x:hidden;overflow-y:auto; + border-bottom:0; + border-right:1px solid var(--border-soft); + background:rgba(10,8,14,.28); + /* cancel the horizontal overflow fade */ + -webkit-mask-image:none;mask-image:none; + scrollbar-width:thin; + } + .modal-tabs::before{ + content:"Subsystems"; + padding:0 11px 9px; + font-family:var(--mono);font-size:var(--fs-micro); + letter-spacing:.14em;text-transform:uppercase;color:var(--text-muted); + } + .modal-tab{ + flex:0 0 auto; + justify-content:flex-start; + min-height:34px; + margin-bottom:0; + padding:7px 11px; + text-align:left; + border-bottom:0; + border-left:2px solid transparent; + border-radius:var(--radius-sm); + font-size:var(--fs-xs); + } + .modal-tab:hover{background:rgba(255,255,255,.045)} + .modal-tab.active{ + color:var(--text); + background:rgba(var(--accent-rgb),.16); + border-bottom-color:transparent; + border-left-color:var(--accent); + } + /* the count badge trails the label instead of crowding it */ + .modal-tab .tab-badge{margin-left:auto} +} + +/* ============================================================================ + Utilities + index.html carried 275 inline style attributes. Inline styles beat every + stylesheet rule without !important, which is why the type scale could not + reach the settings labels no matter what the CSS said. These six recurring + declarations are now classes; the remaining ~220 are per-site layout and + dynamic values (progress widths) that need judgement, not a find-replace. + ========================================================================== */ +.u-flex-1{flex:1} +.u-push{margin-left:auto} +.u-row-8{align-items:center;gap:8px} +.u-fs-micro{font-size:var(--fs-micro)} +.u-field-md{min-width:150px} +.u-field-sm{width:140px} + +/* otherwise inherits the browser's 0.83em and compounds below the floor. */ +small,.hint,.toggle-hint{font-size:var(--fs-xs)} +/* An unclassed + + + + + +
    + +
    + No signal +

    Select a video source above to begin

    +
    + +
    + + + + + + diff --git a/schemas/chimera-title-manifest.schema.json b/schemas/chimera-title-manifest.schema.json new file mode 100644 index 0000000..aa944e4 --- /dev/null +++ b/schemas/chimera-title-manifest.schema.json @@ -0,0 +1,211 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://chimera.local/schemas/chimera-title-manifest.schema.json", + "title": "Portable Chimera title manifest", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "manifestId", + "createdAt", + "title", + "components", + "recipes", + "assets", + "provenance" + ], + "properties": { + "schemaVersion": { + "const": 1 + }, + "manifestId": { + "type": "string", + "minLength": 1 + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "title": { + "type": "object", + "additionalProperties": false, + "required": [ + "platform", + "name" + ], + "properties": { + "platform": { + "enum": [ + "ps4", + "ps5", + "crossgen", + "unknown" + ] + }, + "name": { + "type": "string", + "minLength": 1 + }, + "titleId": { + "type": [ + "string", + "null" + ] + }, + "contentId": { + "type": [ + "string", + "null" + ] + }, + "npCommunicationId": { + "type": [ + "string", + "null" + ] + }, + "region": { + "type": [ + "string", + "null" + ] + } + } + }, + "components": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "type", + "relativePath", + "required" + ], + "properties": { + "id": { + "type": "string" + }, + "type": { + "enum": [ + "base", + "update", + "dlc", + "unlocker", + "auxiliary", + "patch_bundle", + "apr_ampr", + "dump_folder", + "image", + "metadata" + ] + }, + "relativePath": { + "type": "string", + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+" + }, + "required": { + "type": "boolean" + }, + "version": { + "type": [ + "string", + "null" + ] + }, + "contentId": { + "type": [ + "string", + "null" + ] + }, + "sizeBytes": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "sha256": { + "type": [ + "string", + "null" + ], + "pattern": "^(?:[a-fA-F0-9]{64})?$" + }, + "provenance": { + "type": "string" + } + } + } + }, + "recipes": { + "type": "array", + "items": { + "type": "string" + } + }, + "assets": { + "type": "array", + "items": { + "type": "object", + "required": [ + "type", + "relativePath", + "provenance" + ], + "properties": { + "type": { + "enum": [ + "cover", + "background", + "screenshot", + "video", + "other" + ] + }, + "relativePath": { + "type": "string" + }, + "provenance": { + "type": "string" + }, + "preferred": { + "type": "boolean" + } + } + } + }, + "installNotes": { + "type": "string" + }, + "provenance": { + "type": "object", + "required": [ + "importJobId", + "sourceType" + ], + "properties": { + "importJobId": { + "type": "string" + }, + "sourceType": { + "type": "string" + }, + "sourceName": { + "type": [ + "string", + "null" + ] + }, + "sourceSha256": { + "type": [ + "string", + "null" + ] + } + } + } + } +} diff --git a/schemas/compatibility-provider.schema.json b/schemas/compatibility-provider.schema.json new file mode 100644 index 0000000..22459dc --- /dev/null +++ b/schemas/compatibility-provider.schema.json @@ -0,0 +1,158 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://chimera.local/schemas/compatibility-provider.schema.json", + "title": "Chimera compatibility provider payload", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "provider", + "revision", + "generatedAt", + "findings" + ], + "properties": { + "schemaVersion": { + "const": 1 + }, + "provider": { + "type": "string", + "minLength": 1 + }, + "revision": { + "type": "string", + "minLength": 1 + }, + "generatedAt": { + "type": "string", + "format": "date-time" + }, + "findings": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "title", + "state", + "summary", + "applicability", + "confidence" + ], + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "object", + "required": [ + "platform" + ], + "properties": { + "platform": { + "enum": [ + "ps4", + "ps5" + ] + }, + "titleId": { + "type": [ + "string", + "null" + ] + }, + "contentId": { + "type": [ + "string", + "null" + ] + } + } + }, + "state": { + "enum": [ + "compatible", + "incompatible", + "workaround", + "unknown" + ] + }, + "severity": { + "enum": [ + "info", + "warning", + "blocking" + ] + }, + "summary": { + "type": "string" + }, + "details": { + "type": "string" + }, + "applicability": { + "type": "object", + "properties": { + "gameVersion": { + "type": [ + "string", + "null" + ] + }, + "firmwareRange": { + "type": [ + "string", + "null" + ] + }, + "loaderRange": { + "type": [ + "string", + "null" + ] + }, + "storageTypes": { + "type": "array", + "items": { + "enum": [ + "internal", + "m2", + "usb", + "server", + "unknown" + ] + } + }, + "formats": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "requires": { + "type": "array", + "items": { + "type": "string" + } + }, + "confidence": { + "enum": [ + "low", + "medium", + "high" + ] + }, + "sourceUrl": { + "type": [ + "string", + "null" + ] + } + } + } + } + } +} diff --git a/schemas/install-recipe.schema.json b/schemas/install-recipe.schema.json new file mode 100644 index 0000000..330ec8d --- /dev/null +++ b/schemas/install-recipe.schema.json @@ -0,0 +1,117 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://chimera.local/schemas/install-recipe.schema.json", + "title": "Chimera install recipe", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "recipeId", + "titleRef", + "version", + "steps", + "createdAt" + ], + "properties": { + "schemaVersion": { + "const": 1 + }, + "recipeId": { + "type": "string", + "minLength": 1 + }, + "titleRef": { + "type": "string", + "minLength": 1 + }, + "version": { + "type": "integer", + "minimum": 1 + }, + "name": { + "type": "string" + }, + "notes": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "steps": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "type", + "dependsOn", + "required", + "parameters" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9._-]+$" + }, + "type": { + "enum": [ + "verify_artifact", + "extract_stage", + "apply_patch_bundle", + "build_image", + "transfer", + "install_ps4_pkg", + "stage_ps5_content", + "mount_register", + "copy_auxiliary", + "compatibility_preflight", + "wait_job", + "scan_reconcile", + "assert_install" + ] + }, + "dependsOn": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "required": { + "type": "boolean" + }, + "componentRef": { + "type": [ + "string", + "null" + ] + }, + "destinationCapability": { + "type": [ + "string", + "null" + ] + }, + "adapter": { + "type": [ + "string", + "null" + ] + }, + "parameters": { + "type": "object" + }, + "idempotencyKeyTemplate": { + "type": [ + "string", + "null" + ] + } + } + } + } + } +} diff --git a/schemas/ps4-package-backup-manifest.schema.json b/schemas/ps4-package-backup-manifest.schema.json new file mode 100644 index 0000000..5f897cf --- /dev/null +++ b/schemas/ps4-package-backup-manifest.schema.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://chimera.local/schemas/ps4-package-backup-manifest.schema.json", + "title": "Chimera verified PS4 package backup manifest", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "kind", "createdAt", "title", "components", "recipe", "dependencies"], + "properties": { + "schemaVersion": { "const": 1 }, + "kind": { "const": "chimera.ps4-package-backup.v1" }, + "createdAt": { "type": "integer", "minimum": 0 }, + "title": { + "type": "object", + "additionalProperties": false, + "required": ["platform", "titleId", "name"], + "properties": { "platform": { "const": "ps4" }, "titleId": { "type": "string", "pattern": "^CUSA[0-9]{5}$" }, "name": { "type": "string" } } + }, + "components": { + "type": "array", "minItems": 1, "maxItems": 4, + "items": { + "type": "object", + "required": ["id", "type", "version", "contentId", "size", "sha256", "absolutePath", "required", "order", "adapterHint", "provenance"], + "properties": { + "id": { "enum": ["base", "update", "dlc", "unlocker"] }, + "type": { "enum": ["base", "update", "dlc", "unlocker"] }, + "version": { "type": "string" }, "contentId": { "type": "string" }, "size": { "type": "integer", "minimum": 1 }, + "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, "absolutePath": { "type": "string", "minLength": 1 }, + "originalRelativePath": { "type": "string" }, "normalizedRelativePath": { "type": "string" }, "required": { "type": "boolean" }, + "order": { "type": "integer", "minimum": 1 }, "adapterHint": { "const": "ps4-package" }, "provenance": { "type": "array" } + } + } + }, + "recipe": { "type": "object", "required": ["installOrder"], "properties": { "installOrder": { "type": "array", "minItems": 1 } } }, + "dependencies": { "type": "array" } + } +} diff --git a/schemas/ps5-native-package-manifest.schema.json b/schemas/ps5-native-package-manifest.schema.json new file mode 100644 index 0000000..3580336 --- /dev/null +++ b/schemas/ps5-native-package-manifest.schema.json @@ -0,0 +1,110 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://chimera.local/schemas/ps5-native-package-manifest.schema.json", + "title": "Chimera PS5 fPKG package manifest v2", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "kind", "createdAt", "title", "packageFormat", "builder", "source", "evidence", "components", "dependencies", "recipe", "launchVerified", "productionRestorable"], + "properties": { + "schemaVersion": { "const": 2 }, + "kind": { "const": "chimera.ps5-fpkg-package.v2" }, + "createdAt": { "type": "number" }, + "title": { + "type": "object", + "additionalProperties": false, + "required": ["platform", "titleId", "name"], + "properties": { + "platform": { "const": "ps5" }, + "titleId": { "type": "string", "pattern": "^[A-Z]{4}[0-9]{5}$" }, + "name": { "type": "string", "minLength": 1 } + } + }, + "packageFormat": { + "type": "object", + "additionalProperties": false, + "required": ["family", "extension", "container", "magic", "debug", "licenseFree", "retail"], + "properties": { + "family": { "const": "ps5-fpkg" }, + "extension": { "const": ".pkg" }, + "container": { "const": "FIH" }, + "magic": { "const": "7f464948" }, + "debug": { "const": true }, + "licenseFree": { "const": true }, + "retail": { "const": false } + } + }, + "builder": { + "type": "object", + "additionalProperties": false, + "required": ["product", "version", "revision", "api", "bridgeAbi", "patched"], + "properties": { + "product": { "const": "LibProsperoPkg" }, + "version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "revision": { "type": "string", "pattern": "^[a-f0-9]{40}$" }, + "api": { "enum": ["ProsperoBackupConverter", "ProsperoPackageBuilder"] }, + "bridgeAbi": { "const": "chimera-prospero-bridge-v4" }, + "patched": { "const": false } + } + }, + "source": { "$ref": "#/$defs/sourceEvidence" }, + "evidence": { + "type": "object", + "additionalProperties": false, + "required": ["backupReadiness", "structuralValidation", "staticLaunchReadiness", "consoleInstall", "consoleLaunch"], + "properties": { + "backupReadiness": { "type": "object" }, + "structuralValidation": { "type": "object" }, + "staticLaunchReadiness": { "type": "object" }, + "consoleInstall": { "$ref": "#/$defs/unprovenEvidence" }, + "consoleLaunch": { "$ref": "#/$defs/unprovenEvidence" } + } + }, + "components": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["id", "type", "titleId", "contentId", "size", "sha256", "absolutePath", "adapterHint", "structuralValidation", "source"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "type": { "enum": ["base", "dlc"] }, + "titleId": { "type": "string", "pattern": "^[A-Z]{4}[0-9]{5}$" }, + "contentId": { "type": "string", "pattern": "^[A-Z]{2}[0-9]{4}-[A-Z]{4}[0-9]{5}_[0-9]{2}-[A-Z0-9]{16}$" }, + "size": { "type": "integer", "minimum": 1 }, + "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "absolutePath": { "type": "string", "minLength": 1 }, + "adapterHint": { "const": "ps5-fpkg-finalized-fih" }, + "structuralValidation": { + "type": "object", + "required": ["status", "upstream"], + "properties": { "status": { "const": "accepted" }, "upstream": { "type": "object", "required": ["accepted"], "properties": { "accepted": { "const": true } } } } + }, + "source": { "$ref": "#/$defs/sourceEvidence" } + } + } + }, + "dependencies": { "type": "array" }, + "recipe": { "type": "object", "required": ["installOrder"] }, + "launchVerified": { "const": false }, + "productionRestorable": { "const": false } + }, + "$defs": { + "sourceEvidence": { + "type": "object", + "required": ["kind", "treeDigestAlgorithm", "treeSha256", "fileCount", "size"], + "properties": { + "kind": { "const": "verified-prepared-application-backup" }, + "treeDigestAlgorithm": { "const": "sha256-tree-v1" }, + "treeSha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "fileCount": { "type": "integer", "minimum": 1 }, + "size": { "type": "integer", "minimum": 1 } + } + }, + "unprovenEvidence": { + "type": "object", + "additionalProperties": false, + "required": ["status"], + "properties": { "status": { "const": "unproven" } } + } + } +} diff --git a/schemas/task-queue.schema.json b/schemas/task-queue.schema.json new file mode 100644 index 0000000..ca1c8b0 --- /dev/null +++ b/schemas/task-queue.schema.json @@ -0,0 +1,162 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://chimera.local/schemas/task-queue.schema.json", + "title": "Chimera autonomous task queue", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "project", + "status_values", + "selection_rule", + "tasks" + ], + "properties": { + "schema_version": { + "const": 1 + }, + "project": { + "type": "string", + "minLength": 1 + }, + "prepared_at": { + "type": "string" + }, + "status_values": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "selection_rule": { + "type": "string", + "minLength": 1 + }, + "tasks": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/task" + } + } + }, + "$defs": { + "task": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "epic", + "title", + "priority", + "status", + "dependencies", + "summary", + "scope", + "done_when", + "required_tests", + "risk", + "requires_live_hardware", + "completion_policy", + "documentation", + "notes" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^CH-[0-9]{4}$" + }, + "epic": { + "type": "string", + "minLength": 1 + }, + "title": { + "type": "string", + "minLength": 1 + }, + "priority": { + "enum": [ + "P0", + "P1", + "P2", + "P3" + ] + }, + "status": { + "enum": [ + "pending", + "in_progress", + "done", + "blocked_external", + "skipped" + ] + }, + "dependencies": { + "type": "array", + "items": { + "type": "string", + "pattern": "^CH-[0-9]{4}$" + }, + "uniqueItems": true + }, + "dependency_policy": { + "enum": [ + "done_only", + "done_or_evidenced_blocked_external" + ] + }, + "summary": { + "type": "string", + "minLength": 1 + }, + "scope": { + "type": "array", + "items": { + "type": "string" + } + }, + "done_when": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + }, + "required_tests": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + }, + "risk": { + "enum": [ + "low", + "medium", + "high", + "critical" + ] + }, + "requires_live_hardware": { + "type": "boolean" + }, + "completion_policy": { + "type": "string", + "minLength": 1 + }, + "documentation": { + "type": "array", + "items": { + "type": "string" + } + }, + "notes": { + "type": "string" + } + } + } + } +} diff --git a/schemas/trophy-local-inventory.schema.json b/schemas/trophy-local-inventory.schema.json new file mode 100644 index 0000000..adb2240 --- /dev/null +++ b/schemas/trophy-local-inventory.schema.json @@ -0,0 +1,78 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://chimera.local/schemas/trophy-local-inventory.schema.json", + "title": "Chimera read-only local PS5 trophy inventory", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "provider", "profile", "coverage", "observedAt", "titles"], + "properties": { + "schemaVersion": { "const": 1 }, + "provider": { + "type": "object", + "additionalProperties": false, + "required": ["id", "version", "firmware", "capabilities"], + "properties": { + "id": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" }, + "version": { "type": "string", "minLength": 1, "maxLength": 64 }, + "firmware": { "type": "string", "minLength": 1, "maxLength": 32 }, + "capabilities": { + "type": "array", + "uniqueItems": true, + "items": { "enum": ["profile-identity", "list-title-sets", "list-earned-trophies", "earned-timestamps"] } + } + } + }, + "profile": { + "type": "object", + "additionalProperties": false, + "required": ["profileId", "localUserId"], + "properties": { + "profileId": { "type": "string", "minLength": 1, "maxLength": 64 }, + "localUserId": { "type": "string", "minLength": 1, "maxLength": 64 }, + "accountId": { "type": ["string", "null"], "maxLength": 64 } + } + }, + "coverage": { + "type": "object", + "additionalProperties": false, + "required": ["status", "completedScopes", "failedScopes"], + "properties": { + "status": { "enum": ["complete", "partial", "inaccessible"] }, + "completedScopes": { "type": "array", "maxItems": 256, "items": { "type": "string", "maxLength": 128 } }, + "failedScopes": { "type": "array", "maxItems": 256, "items": { "type": "string", "maxLength": 128 } } + } + }, + "observedAt": { "type": "string", "format": "date-time" }, + "titles": { + "type": "array", + "maxItems": 1000, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["npCommunicationId", "platform", "service", "trophies"], + "properties": { + "npCommunicationId": { "type": "string", "maxLength": 64 }, + "titleId": { "type": "string", "maxLength": 10 }, + "platform": { "enum": ["PS4", "PS5"] }, + "service": { "enum": ["trophy", "trophy2"] }, + "trophies": { + "type": "array", + "maxItems": 5000, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["groupId", "trophyId", "earned"], + "properties": { + "groupId": { "type": "string", "maxLength": 64 }, + "trophyId": { "type": "integer", "minimum": 0, "maximum": 99999 }, + "earned": { "type": "boolean" }, + "earnedDateTime": { "type": ["string", "null"], "format": "date-time" }, + "definitionHash": { "type": ["string", "null"], "pattern": "^[a-f0-9]{64}$" } + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/schemas/trophy-psn-proof.schema.json b/schemas/trophy-psn-proof.schema.json new file mode 100644 index 0000000..51555c9 --- /dev/null +++ b/schemas/trophy-psn-proof.schema.json @@ -0,0 +1,184 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://chimera.local/schemas/trophy-psn-proof.schema.json", + "title": "Read-only PSN trophy evidence snapshot", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "snapshotId", + "provider", + "fetchedAt", + "account", + "titles" + ], + "properties": { + "schemaVersion": { + "const": 1 + }, + "snapshotId": { + "type": "string", + "minLength": 1 + }, + "provider": { + "type": "object", + "required": [ + "id", + "version", + "mode" + ], + "properties": { + "id": { + "type": "string" + }, + "version": { + "type": "string" + }, + "mode": { + "enum": [ + "json-import", + "authorized-read-only" + ] + } + } + }, + "fetchedAt": { + "type": "string", + "format": "date-time" + }, + "account": { + "type": "object", + "additionalProperties": false, + "required": [ + "accountId" + ], + "properties": { + "accountId": { + "type": "string", + "minLength": 1 + }, + "onlineId": { + "type": [ + "string", + "null" + ] + }, + "region": { + "type": [ + "string", + "null" + ] + } + } + }, + "titles": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "platform", + "npServiceName", + "npCommunicationId", + "trophies" + ], + "properties": { + "platform": { + "enum": [ + "PS3", + "PS4", + "PS5", + "PSVITA", + "PC", + "UNKNOWN" + ] + }, + "npServiceName": { + "enum": [ + "trophy", + "trophy2" + ] + }, + "npCommunicationId": { + "type": "string", + "minLength": 1 + }, + "titleId": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "trophies": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "groupId", + "trophyId", + "earned" + ], + "properties": { + "groupId": { + "type": "string" + }, + "trophyId": { + "type": "integer", + "minimum": 0 + }, + "earned": { + "type": "boolean" + }, + "earnedDateTime": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "type": { + "enum": [ + "bronze", + "silver", + "gold", + "platinum", + "unknown" + ] + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "hidden": { + "type": "boolean" + } + } + } + } + } + } + }, + "canonicalSha256": { + "type": [ + "string", + "null" + ], + "pattern": "^(?:[a-fA-F0-9]{64})?$" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + } + } +} diff --git a/scripts/check-syntax.js b/scripts/check-syntax.js new file mode 100644 index 0000000..1e63e71 --- /dev/null +++ b/scripts/check-syntax.js @@ -0,0 +1,62 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const vm = require('vm'); + +const root = path.resolve(__dirname, '..'); +const skipDirs = new Set(['.git', 'node_modules', 'dist', 'output', '.playwright-cli', 'coverage']); +const files = []; + +function walk(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (!skipDirs.has(entry.name)) walk(path.join(dir, entry.name)); + } else if (entry.isFile() && entry.name.endsWith('.js')) { + files.push(path.join(dir, entry.name)); + } + } +} + +walk(root); + +let failures = 0; +for (const file of files.sort()) { + const code = fs.readFileSync(file, 'utf8'); + const rel = path.relative(root, file).replace(/\\/g, '/'); + const isModule = /^\s*(?:import|export)\s/m.test(code); + const result = isModule + ? spawnSync(process.execPath, ['--input-type=module', '--check'], { input: code, encoding: 'utf8' }) + : spawnSync(process.execPath, ['--check', file], { encoding: 'utf8' }); + + let parseError = null; + if (result.error?.code === 'EPERM') { + try { + if (isModule) { + if (typeof vm.SourceTextModule !== 'function') { + throw new Error('Node vm.SourceTextModule is unavailable; rerun with NODE_OPTIONS=--experimental-vm-modules'); + } + new vm.SourceTextModule(code, { identifier: file }); + } else { + new vm.Script(code, { filename: file }); + } + } catch (error) { + parseError = error; + } + } + + if ((result.error?.code === 'EPERM' && parseError) || (result.error?.code !== 'EPERM' && result.status !== 0)) { + failures += 1; + const output = parseError?.stack || `${result.stdout || ''}${result.stderr || ''}`.trim() || result.error?.stack || ''; + console.error(`Syntax check failed: ${rel}`); + if (output) console.error(output); + } +} + +if (failures) { + console.error(`JavaScript syntax check failed for ${failures}/${files.length} files.`); + process.exit(1); +} + +console.log(`JavaScript syntax check passed (${files.length} files).`); diff --git a/server.js b/server.js new file mode 100644 index 0000000..5a1d1cc --- /dev/null +++ b/server.js @@ -0,0 +1,192 @@ +'use strict'; + +const fs = require('fs'); +const net = require('net'); +const path = require('path'); +const pkg = require('./package.json'); +const db = require('./src/database'); +const ftp = require('./src/ftp'); +const { initializeDataPaths } = require('./src/platform/data-paths'); +const { readBuildInfo } = require('./src/platform/build-info'); +const { createLogger } = require('./src/platform/logger'); +const { SettingsStore } = require('./src/persistence/settings-store'); +const { SecretsStore } = require('./src/security/secrets-store'); +const { createRegistry } = require('./src/server/create-registry'); +const { createAppServer } = require('./src/server/app-server'); + +const appRoot = __dirname; +const paths = initializeDataPaths(); +const logger = createLogger(paths.logs); +const settings = new SettingsStore(path.join(paths.config, 'settings.json')); +const keyFile = process.env.APP_SECRET_KEY_FILE || path.join(paths.secrets, 'master.key'); +const secrets = new SecretsStore({ filePath: path.join(paths.secrets, 'secrets.enc.json'), keyFile }); + +const capabilities = { + platform: 'linux-server', language: { default: 'en', fallback: 'en' }, + features: { + browserUi: { available: true, mode: 'native-web' }, ftp: { available: true }, + https: { available: true, mode: 'reverse-proxy', configured: Boolean(process.env.CHIMERA_PUBLIC_ORIGIN || process.env.PORKFOLIO_PUBLIC_ORIGIN) }, + gamesDatabase: { available: true }, transferEvents: { available: true, transport: 'websocket' }, + systemView: { available: true, mode: 'browser-media-devices', secureContextRequired: true }, + ffpkgConversion: { available: false, reason: 'Windows worker required.' }, + ffpfscConversion: { available: true, mode: 'native-worker' }, + exfatConversion: { available: false, reason: 'Windows worker required.' }, + arbitrarySqlWrite: { available: false, reason: 'SQL Explorer is read-only in Docker.' }, + shellAccess: { available: false, reason: 'General shell access is intentionally disabled.' }, + localGameLibraries: { available: true, roots: ['/games/ps4', '/games/ps5'], readOnly: true }, + elfArsenal: { available: true, requiresPayload: true, port: 6969 }, + payloadManager: { available: true, requiresPayload: true, port: 8084 }, + cheats: { available: true, mode: 'server-cache-and-ftp' }, + localSaveManager: { available: true, requiresPayload: true, port: 8082 }, + media: { available: true, mode: 'ftp-cache' }, + localBackups: { available: true, roots: ['/games/ps4', '/games/ps5', '/game-backups'] }, + backporkGenerator: { available: true, mode: 'native-worker', pupUnpacker: '/usr/local/bin/ps5-pup-unpacker' }, + bdjbGenerator: { available: true, mode: 'bundled-linux-sdk' }, + y2jbGenerator: { available: true, mode: 'server-download' }, + ecosystemHub: { available: true, mode: 'official-upstream-connectors' }, + compatibilityEngine: { available: true, automaticExecution: false }, + consoleHealth: { available: true, scheduled: true, notifications: 'optional-discord' }, + safeUpdatePlanner: { available: true, dependencyOrder: true, automaticExecution: false }, + payloadManagerRepository: { available: true, path: '/payloads.json', cors: true, checksums: true }, + websrvDiscovery: { available: true, endpoint: '/mdns' }, + homebrewHub: { available: true, requiresPayload: true, backend: 'ps5-payload-dev/websrv', port: 8080 }, + remoteSources: { available: true, protocols: ['smb', 'nfs', 'sftp', 'ftp', 'webdav', 'http', 'https'], credentialsEncrypted: true }, + etaHenProfiles: { available: true, snapshots: true, uploadVerification: true }, + etaHenPlugins: { available: true, usbPriorityAware: true }, + cheatsV2: { available: true, sources: ['etaHEN/PS5_Cheats', 'GoldHEN/GoldHEN_Cheat_Repository', 'illusionyy/PS-Game-Patch'] }, + saveVault: { available: true, backend: 'earthonion/garlic-savemgr', port: 8082 }, + consoleTimeline: { available: true, ports: [9081, 3232], expertShellProxy: false }, + gameUpdateCenter: { available: true, officialManifestHostsOnly: true, pieceAndPackageHashes: true, automaticInstall: false }, + payloadSupplyChain: { available: true, localRehash: true, officialSources: true, automaticExecution: false }, + liveSession: { available: true, backend: 'ps5debug-NG', readOnlyCommands: 5, memoryAccess: false, kernelAccess: false }, + websrvFileExplorer: { available: true, readOnly: true, fixedRoots: true, smbProfilesOnly: true }, + remotePlayWake: { available: true, backend: 'chiaki-ng-compatible', registrationKeyEncrypted: true }, + remotePlay: { available: true, mode: 'paired-native-client-worker', discovery: true, wake: true, sessionOrchestration: true, embeddedVideo: false, hmacWorker: true }, + itemzflowJobs: { available: true, mode: 'read-only-log-monitor', undocumentedCommands: false }, + backupReplication: { available: true, target: '/replication', sourceDeletion: false, sha256Verification: true, scheduledOptIn: true }, + prometheusMetrics: { available: true, path: '/metrics', bearerTokenRequired: true }, + sessionManagement: { available: true, revoke: true, passwordRotation: true }, + pacbrewCatalog: { available: true, mode: 'read-only-recipes', install: false }, + ps5LinuxLab: { available: true, mode: 'read-only-preflight', payloadSend: false, blockDeviceWrite: false }, + auditCenter: { available: true, filters: true, boundedRetention: true, authenticatedExport: true }, + recoveryVault: { available: true, schema: 2, sha256: true, externalImport: true, automaticRestore: false }, + shadowMountPreflight: { available: true, invalidConfigBlocked: true, criticalConfirmation: true }, + passiveKlog: { available: true, port: 3232, zeroWrite: true, payloadLaunch: false, explicitStart: true }, + }, +}; + +function backupDatabase() { + const chimeraSource = path.join(paths.database, 'chimera.db'); + const legacySource = path.join(paths.database, 'porkfolio.db'); + const source = fs.existsSync(chimeraSource) ? chimeraSource : legacySource; + if (!fs.existsSync(source)) return; + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + fs.copyFileSync(source, path.join(paths.backups, `chimera-${stamp}.db`)); + const retention = Math.max(1, Number(settings.get('backup.retention', 14)) || 14); + const backups = fs.readdirSync(paths.backups).filter(name => /^chimera-.*\.db$/.test(name)).sort().reverse(); + for (const stale of backups.slice(retention)) fs.unlinkSync(path.join(paths.backups, stale)); +} + +let appServer; +let registryInstance; +let shuttingDown = false; + +function portReachable(host, port, timeoutMs = 1_500) { + return new Promise(resolve => { + const socket = net.createConnection({ host, port }); + let settled = false; + const finish = reachable => { + if (settled) return; + settled = true; + socket.destroy(); + resolve(reachable); + }; + socket.setTimeout(timeoutMs); + socket.once('connect', () => finish(true)); + socket.once('timeout', () => finish(false)); + socket.once('error', () => finish(false)); + }); +} + +async function autoConnectFtp() { + if (!settings.get('autoConnect', true)) { + logger.info('[FTP] Automatisch verbinden is uitgeschakeld'); + return { connected: false, reason: 'disabled' }; + } + const host = String(settings.get('ftp.host', '') || '').trim(); + if (!host) { + logger.warn('[FTP] Automatisch verbinden overgeslagen: PS5-host ontbreekt'); + return { connected: false, reason: 'host-missing' }; + } + const configured = settings.get('ftp.ports', ['1337', '2121', '21']); + const preferred = settings.get('ftp.port', '1337'); + const ports = [...new Set([preferred, ...(Array.isArray(configured) ? configured : [configured])] + .map(Number) + .filter(port => Number.isInteger(port) && port >= 1 && port <= 65535))]; + const errors = []; + const reachability = new Map(await Promise.all(ports.map(async port => [port, await portReachable(host, port)]))); + for (const port of ports) { + if (!reachability.get(port)) { + errors.push({ port, error: 'TCP-poort niet bereikbaar' }); + continue; + } + try { + await ftp.connect({ + host, + port, + user: String(settings.get('ftp.user', '') || ''), + password: String(secrets.get('ftp.password') || ''), + }); + settings.set('ftp.port', String(port)); + logger.info('[FTP] PS5 automatisch verbonden', { host, port }); + return { connected: true, host, port }; + } catch (error) { + errors.push({ port, error: error.message }); + } + } + logger.warn('[FTP] Automatisch verbinden met de PS5 mislukt', { host, attempts: errors }); + return { connected: false, reason: 'unreachable', host, attempts: errors }; +} + +async function shutdown(signal) { + if (shuttingDown) return; + shuttingDown = true; + logger.info('Graceful shutdown gestart', { signal }); + try { registryInstance?.maintenance?.stopOperations?.(); } catch (_) {} + try { await registryInstance?.ps5P15A10Runtime?.stopEngine?.(); } catch (error) { logger.warn('[PS5Upload] diagnostische engine stoppen mislukt', { error: error.message }); } + try { await registryInstance?.ps5ProfileRuntime?.stopEngine?.(); } catch (error) { logger.warn('[PS5Upload] productie-engine stoppen mislukt', { error: error.message }); } + try { await ftp.disconnect(); } catch (_) {} + try { db.close(); } catch (error) { logger.error('Database sluiten mislukt', error); } + try { if (appServer) await appServer.close(); } catch (error) { logger.error('HTTP-server sluiten mislukt', error); } + logger.info('Graceful shutdown voltooid'); +} + +async function main() { + backupDatabase(); + db.configure({ dataDir: paths.database, logger }); + await db.initialize(); + const registry = createRegistry({ db, ftp, settings, secrets, paths, logger, capabilities, appRoot, broadcast: (channel, ...args) => appServer?.broadcast(channel, ...args) }); + registryInstance = registry; + appServer = createAppServer({ appRoot, paths, settings, secrets, registry, logger, ftp, capabilities, version: pkg.version }); + const port = Number(process.env.PORT || 6967); + const host = process.env.HOST || '0.0.0.0'; + await appServer.listen(port, host); + logger.info('Chimera server gestart', { host, port, dataRoot: paths.root, version: pkg.version, build: readBuildInfo() }); + try { await registry.ps5ProfileRuntime.startEngine(); } + catch (error) { logger.warn('[PS5Upload] engine autostart geblokkeerd', { code: error.code, error: error.message }); } + registry.maintenance.seedPayloadSources(); + if (settings.get('admin.passwordHash', '')) { + await autoConnectFtp(); + registry.maintenance.startOperations(); + } else { + logger.info('[Operations] Taakplanner wacht tot de eerste configuratie is voltooid'); + } +} + +process.on('SIGTERM', () => shutdown('SIGTERM').finally(() => process.exit(0))); +process.on('SIGINT', () => shutdown('SIGINT').finally(() => process.exit(0))); +process.on('uncaughtException', error => { logger.error('Uncaught exception', error); shutdown('uncaughtException').finally(() => process.exit(1)); }); +process.on('unhandledRejection', error => logger.error('Unhandled rejection', error)); + +if (require.main === module) main().catch(error => { logger.error('Serverstart mislukt', error); process.exitCode = 1; }); +module.exports = { main, shutdown, capabilities, autoConnectFtp, portReachable }; diff --git a/src/backpork-gen/decrypt-fself.js b/src/backpork-gen/decrypt-fself.js new file mode 100644 index 0000000..1867249 --- /dev/null +++ b/src/backpork-gen/decrypt-fself.js @@ -0,0 +1,137 @@ +'use strict'; + +// ── SELF → ELF Extractor ─────────────────────────────────────────────────────── +// Ports Auto-Backpork/src/decrypt_fself.py (john-tornblom / NazkyYT) +// to pure Node.js Buffer operations — no Python required. +// +// "Fake-signed" SELF files store ELF segment data in plaintext — there is no +// actual cryptographic decryption needed. We just need to reassemble the ELF +// from the segment data stored in the SELF container. + +const { ELF_MAGIC, PS4_SELF_MAGIC, PS5_SELF_MAGIC } = require('./sdk-patcher'); + +function alignUp(x, a) { return (x + a - 1) & ~(a - 1); } + +// ── SELF header layout (all little-endian) ──────────────────────────────────── +// Offset Size Field +// 0 4 magic (PS4: 4F 15 3D 1D, PS5: 54 14 F5 EE) +// 4 1 version +// 5 1 mode +// 6 1 endian +// 7 1 attrs +// 8 4 key_type (uint32) +// 12 2 header_size (uint16) +// 14 2 meta_size (uint16) +// 16 8 file_size (uint64) +// 24 2 num_entries (uint16) +// 26 2 flags (uint16) +// 28 4 padding +// 32+ n*32 entries array +// +// Each entry (32 bytes): +// 0 8 props (uint64) — bitfield flags + segment index +// 8 8 offset (uint64) — file offset of this segment's data in the SELF +// 16 8 enc_size(uint64) — data size in SELF (filesz) +// 24 8 dec_size(uint64) — decoded size (memsz) +// +// props bitfield: +// bit 11 = has_blocks (set → this is a data entry, not digest) +// bits 20-35 = segment_index (which ELF phdr this belongs to) + +const SELF_HDR_COMMON = 8; // magic+attrs +const SELF_HDR_EXT = 24; // key_type..padding +const ENTRY_SIZE = 32; + +const PROPS_HAS_BLOCKS_BIT = 11n; +const PROPS_SEG_INDEX_SHIFT = 20n; +const PROPS_SEG_INDEX_MASK = 0xFFFFn; + +// ELF program-header field offsets (within each 56-byte phdr entry) +const PHDR_TYPE_OFF = 0; // uint32 +const PHDR_OFFSET_OFF = 8; // uint64 +const PHDR_FILESZ_OFF = 32; // uint64 + +// ELF header field offsets +const ELF_PHOFF_OFF = 0x20; // uint64 +const ELF_EHSIZE_OFF = 0x34; // uint16 +const ELF_PHENTSIZE_OFF = 0x36; // uint16 +const ELF_PHNUM_OFF = 0x38; // uint16 + +/** + * Returns true if the buffer is a fake-signed SELF file. + */ +function isSelf(buf) { + if (buf.length < 4) return false; + return buf.slice(0, 4).equals(PS4_SELF_MAGIC) || buf.slice(0, 4).equals(PS5_SELF_MAGIC); +} + +/** + * Extract the unsigned ELF from a fake-signed SELF Buffer. + * @param {Buffer} selfBuf + * @returns {Buffer} the extracted ELF + */ +function extractElf(selfBuf) { + if (!isSelf(selfBuf)) throw new Error('Not a fake-signed SELF file'); + + // Parse extended header + const numEntries = selfBuf.readUInt16LE(24); + + // Parse entry table (starts at byte 32) + const entries = []; + for (let i = 0; i < numEntries; i++) { + const base = 32 + i * ENTRY_SIZE; + const props = selfBuf.readBigUInt64LE(base); + const offset = Number(selfBuf.readBigUInt64LE(base + 8)); + const encSize = Number(selfBuf.readBigUInt64LE(base + 16)); + const hasBlocks = ((props >> PROPS_HAS_BLOCKS_BIT) & 1n) === 1n; + const segIndex = Number((props >> PROPS_SEG_INDEX_SHIFT) & PROPS_SEG_INDEX_MASK); + entries.push({ offset, encSize, hasBlocks, segIndex }); + } + + // The ELF header is embedded in the SELF right after the entry table, aligned to 16 + const elfOff = alignUp(32 + numEntries * ENTRY_SIZE, 16); + + if (!selfBuf.slice(elfOff, elfOff + 4).equals(ELF_MAGIC)) { + throw new Error(`ELF magic not found at SELF offset 0x${elfOff.toString(16)}`); + } + + const ehsize = selfBuf.readUInt16LE(elfOff + ELF_EHSIZE_OFF); + const phentsize = selfBuf.readUInt16LE(elfOff + ELF_PHENTSIZE_OFF); + const phnum = selfBuf.readUInt16LE(elfOff + ELF_PHNUM_OFF); + const phoff = Number(selfBuf.readBigUInt64LE(elfOff + ELF_PHOFF_OFF)); + + // Parse embedded program headers + const phdrs = []; + for (let i = 0; i < phnum; i++) { + const base = elfOff + phoff + i * phentsize; + const offset = Number(selfBuf.readBigUInt64LE(base + PHDR_OFFSET_OFF)); + const filesz = Number(selfBuf.readBigUInt64LE(base + PHDR_FILESZ_OFF)); + phdrs.push({ offset, filesz }); + } + + // Calculate output ELF size: max(ehsize, phdr table end, last segment end) + let elfSize = Math.max(ehsize, phoff + phnum * phentsize); + for (const ph of phdrs) { + if (ph.filesz > 0) elfSize = Math.max(elfSize, ph.offset + ph.filesz); + } + + const elfBuf = Buffer.alloc(elfSize, 0); + + // Copy ELF header + program headers verbatim from SELF + const elfHdrBytes = phoff + phnum * phentsize; + selfBuf.copy(elfBuf, 0, elfOff, elfOff + elfHdrBytes); + + // Copy segment data from SELF into the output ELF + const dataEntries = entries.filter(e => e.hasBlocks); + for (const entry of dataEntries) { + if (entry.segIndex >= phdrs.length) continue; + const ph = phdrs[entry.segIndex]; + if (ph.filesz === 0) continue; + const copyLen = Math.min(entry.encSize, ph.filesz); + selfBuf.copy(elfBuf, ph.offset, entry.offset, entry.offset + copyLen); + } + + return elfBuf; +} + +module.exports = { isSelf, extractElf }; diff --git a/src/backpork-gen/fakelib-manager.js b/src/backpork-gen/fakelib-manager.js new file mode 100644 index 0000000..41d7890 --- /dev/null +++ b/src/backpork-gen/fakelib-manager.js @@ -0,0 +1,281 @@ +'use strict'; + +// ── Fakelib Manager — Core Logic ───────────────────────────────────────────── +// Handles obtaining fakelib (.sprx/.prx) files from decrypted PS5 firmware PUPs. +// +// Pipeline (adapted from PS5-BACKPORK-KITCHEN by rajeshca911): +// 1. Ensure ps5-pup-unpacker tool is present (auto-download from GitHub) +// 2. User provides a decrypted .PUP.dec file (decrypted on jailbroken PS5 +// using ps5_pup_decrypt.elf — see https://github.com/zecoxao/ps5-pup-decrypt) +// 3. Run ps5-pup-unpacker to extract contents +// 4. Collect all .sprx / .prx libraries found in the extraction tree +// 5. Copy them to baseDir// (numbered by SDK pair 1–10) +// +// Fakelibs cannot be distributed directly — they must be extracted from +// official Sony firmware the user already has legal access to. + +const https = require('https'); +const { spawn } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const TOOL_FILENAME = process.platform === 'win32' ? 'ps5-pup-unpacker.exe' : 'ps5-pup-unpacker'; +const BUNDLED_LINUX_TOOL = '/usr/local/bin/ps5-pup-unpacker'; +const GITHUB_API_URL = 'https://api.github.com/repos/zecoxao/ps5-pup-unpacker/releases/latest'; +const MANUAL_DL_URL = 'https://www.psx-place.com/resources/ps5-pup-decrypter-and-unpacker.1449/'; +module.exports = { + TOOL_FILENAME, + MANUAL_DL_URL, + scanFakelibBase, + ensurePupUnpacker, + extractPupToDir, +}; + +// ── scanFakelibBase ─────────────────────────────────────────────────────────── +// Scan a base directory for ANY numbered subfolders (no upper limit). +// Discovers whatever pair folders actually exist (1, 2, ... 30, etc). +// Returns: Array<{ pair, dir, libCount, hasLibs }> +function scanFakelibBase(baseDir) { + const results = []; + try { + const entries = fs.readdirSync(baseDir, { withFileTypes: true }); + const pairNums = entries + .filter(e => e.isDirectory() && /^\d+$/.test(e.name)) + .map(e => parseInt(e.name, 10)) + .sort((a, b) => a - b); + for (const pair of pairNums) { + const dir = path.join(baseDir, String(pair)); + const libCount = countLibrariesRecursive(dir); + results.push({ pair, dir, libCount, hasLibs: libCount > 0 }); + } + } catch {} + return results; +} + +function countLibrariesRecursive(dir) { + let total = 0; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) total += countLibrariesRecursive(full); + else if (/\.(sprx|prx)$/i.test(entry.name)) total++; + } + return total; +} + +// ── ensurePupUnpacker ───────────────────────────────────────────────────────── +// Ensures ps5-pup-unpacker.exe is available in toolsDir. +// Attempts to download latest release from GitHub; falls back to manual instructions. +// Returns: string — absolute path to the tool exe. +async function ensurePupUnpacker(toolsDir, onProgress) { + if (process.platform !== 'win32') { + const candidates = [process.env.PUP_UNPACKER_PATH, BUNDLED_LINUX_TOOL, path.join(toolsDir, TOOL_FILENAME)].filter(Boolean); + const toolPath = candidates.find(candidate => { + try { fs.accessSync(candidate, fs.constants.R_OK | fs.constants.X_OK); return true; } catch (_) { return false; } + }); + if (toolPath) { + onProgress?.({ stage: 'tool-ready', pct: 100, msg: `Linux PUP-unpacker gereed: ${toolPath}` }); + return toolPath; + } + throw new Error(`ps5-pup-unpacker ontbreekt. Verwacht in de container: ${BUNDLED_LINUX_TOOL}`); + } + + const toolPath = path.join(toolsDir, TOOL_FILENAME); + if (fs.existsSync(toolPath)) return toolPath; + + onProgress?.({ stage: 'download-tool', pct: 0, msg: 'Fetching ps5-pup-unpacker from GitHub…' }); + + if (!fs.existsSync(toolsDir)) fs.mkdirSync(toolsDir, { recursive: true }); + + // Try GitHub releases API first + let downloadUrl = null; + try { + const release = await fetchJson(GITHUB_API_URL); + const asset = (release.assets || []).find( + a => a.name === TOOL_FILENAME || (a.name.toLowerCase().endsWith('.exe') && a.name.toLowerCase().includes('unpacker')) + ); + if (asset) downloadUrl = asset.browser_download_url; + } catch (e) { + // GitHub unreachable — fall through to manual error + } + + if (!downloadUrl) { + throw new ManualDownloadRequired( + `Could not auto-download ${TOOL_FILENAME} from GitHub.\n` + + `Please download it manually from:\n${MANUAL_DL_URL}\n` + + `Extract ${TOOL_FILENAME} and place it in:\n${toolsDir}` + ); + } + + onProgress?.({ stage: 'download-tool', pct: 5, msg: 'Downloading ps5-pup-unpacker…' }); + + try { + await downloadFile(downloadUrl, toolPath, pct => + onProgress?.({ stage: 'download-tool', pct: 5 + Math.round(pct * 0.93), msg: `Downloading ps5-pup-unpacker… ${pct}%` }) + ); + } catch (e) { + // Clean up partial download + try { fs.unlinkSync(toolPath); } catch {} + throw new Error(`Failed to download ps5-pup-unpacker: ${e.message}\n\nManual download: ${MANUAL_DL_URL}`); + } + + onProgress?.({ stage: 'download-tool', pct: 99, msg: 'ps5-pup-unpacker downloaded' }); + return toolPath; +} + +// ── extractPupToDir ─────────────────────────────────────────────────────────── +// Extract a decrypted PUP file and copy .sprx/.prx libraries to targetDir. +// opts = { pupPath, targetDir, toolPath, isCancelled?, onProgress? } +// Returns: { libCount } +async function extractPupToDir(opts) { + const { pupPath, targetDir, toolPath, isCancelled, onProgress } = opts; + + if (!fs.existsSync(pupPath)) throw new Error('PUP file not found: ' + pupPath); + if (!fs.existsSync(toolPath)) throw new Error('ps5-pup-unpacker not found: ' + toolPath); + + // Temporary extraction directory alongside targetDir + const tempDir = path.join(path.dirname(targetDir), `_pup_tmp_${Date.now()}`); + fs.mkdirSync(tempDir, { recursive: true }); + + try { + // ── Step 1: Unpack PUP ────────────────────────────────────────────────── + onProgress?.({ stage: 'unpack', pct: 5, msg: 'Running ps5-pup-unpacker…' }); + + await runTool(toolPath, [pupPath, tempDir], line => { + onProgress?.({ stage: 'unpack', pct: 30, msg: line }); + }); + + if (isCancelled?.()) throw new Error('Cancelled'); + + // ── Step 2: Find libraries ─────────────────────────────────────────────── + onProgress?.({ stage: 'find-libs', pct: 55, msg: 'Searching for .sprx/.prx files…' }); + + const libs = []; + walkForLibs(tempDir, libs); + + if (!libs.length) { + throw new Error( + 'No .sprx/.prx library files found in extracted PUP.\n' + + 'Make sure the PUP file is decrypted (.PUP.dec).\n' + + 'Encrypted PUPs must be decrypted on a jailbroken PS5 first.\n' + + 'See: https://github.com/zecoxao/ps5-pup-decrypt' + ); + } + + if (isCancelled?.()) throw new Error('Cancelled'); + + // ── Step 3: Copy libraries to targetDir ───────────────────────────────── + onProgress?.({ stage: 'copy-libs', pct: 60, msg: `Found ${libs.length} libraries — copying…` }); + + if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true }); + + let copied = 0; + for (const libPath of libs) { + if (isCancelled?.()) throw new Error('Cancelled'); + const dest = path.join(targetDir, path.basename(libPath)); + fs.copyFileSync(libPath, dest); + copied++; + const pct = 60 + Math.round((copied / libs.length) * 38); + onProgress?.({ stage: 'copy-libs', pct, msg: `${path.basename(libPath)}` }); + } + + onProgress?.({ stage: 'done', pct: 100, msg: `Extracted ${copied} libraries` }); + return { libCount: copied }; + + } finally { + // Always clean up temp dir + try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch {} + } +} + +// ── Private helpers ─────────────────────────────────────────────────────────── + +function walkForLibs(dir, results) { + try { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walkForLibs(full, results); + else if (/\.(sprx|prx)$/i.test(entry.name)) results.push(full); + } + } catch {} +} + +function fetchJson(url) { + return new Promise((resolve, reject) => { + const req = https.get(url, { + headers: { 'User-Agent': 'Chimera/2.0', 'Accept': 'application/vnd.github.v3+json' }, + }, res => { + if (res.statusCode === 301 || res.statusCode === 302) { + return fetchJson(res.headers.location).then(resolve, reject); + } + if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`)); + let raw = ''; + res.on('data', c => { raw += c; }); + res.on('end', () => { + try { resolve(JSON.parse(raw)); } + catch { reject(new Error('Invalid JSON from GitHub API')); } + }); + }); + req.on('error', reject); + req.setTimeout(15000, () => { req.destroy(); reject(new Error('GitHub API request timed out')); }); + }); +} + +function downloadFile(url, dest, onPct) { + return new Promise((resolve, reject) => { + const req = https.get(url, { headers: { 'User-Agent': 'Chimera/2.0' } }, res => { + if (res.statusCode === 301 || res.statusCode === 302) { + return downloadFile(res.headers.location, dest, onPct).then(resolve, reject); + } + if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`)); + const total = Number(res.headers['content-length'] || 0); + let received = 0; + const ws = fs.createWriteStream(dest); + res.on('data', chunk => { + received += chunk.length; + ws.write(chunk); + if (total) onPct?.(Math.round((received / total) * 100)); + }); + res.on('end', () => ws.end(resolve)); + res.on('error', reject); + ws.on('error', reject); + }); + req.on('error', reject); + req.setTimeout(300000, () => { req.destroy(); reject(new Error('Download timed out')); }); + }); +} + +function runTool(toolPath, args, onLine) { + return new Promise((resolve, reject) => { + let stderr = ''; + const child = spawn(toolPath, args, { + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + child.stdout.on('data', d => { + for (const line of d.toString().split('\n').filter(Boolean)) onLine(line.trim()); + }); + child.stderr.on('data', d => { stderr += d.toString(); }); + child.on('close', code => { + if (code === 0) return resolve(); + const detail = stderr.trim() || `exit code ${code}`; + // Exit code 148 or "Usage" / ".dec" in output = encrypted PUP + if (code === 148 || detail.toLowerCase().includes('.dec') || detail.toLowerCase().includes('usage')) { + return reject(new Error( + 'The PUP file appears to be encrypted.\n' + + 'You must decrypt it first using ps5_pup_decrypt.elf on a jailbroken PS5.\n' + + 'The output will be a .PUP.dec file — import that file instead.\n' + + 'Reference: https://github.com/zecoxao/ps5-pup-decrypt' + )); + } + reject(new Error(`ps5-pup-unpacker failed: ${detail}`)); + }); + child.on('error', e => { + if (e.code === 'ENOENT') reject(new Error(`${TOOL_FILENAME} not found`)); + else reject(e); + }); + }); +} + +class ManualDownloadRequired extends Error { + constructor(msg) { super(msg); this.name = 'ManualDownloadRequired'; this.manualRequired = true; } +} diff --git a/src/backpork-gen/make-fself.js b/src/backpork-gen/make-fself.js new file mode 100644 index 0000000..f481974 --- /dev/null +++ b/src/backpork-gen/make-fself.js @@ -0,0 +1,217 @@ +'use strict'; + +// ── ELF → Fake-Signed SELF ──────────────────────────────────────────────────── +// Ports Auto-Backpork/src/make_fself.py (john-tornblom / NazkyYT) +// to pure Node.js Buffer operations — no Python required. +// +// Wraps an unsigned ELF file in a fake-signed SELF container. The output is +// structurally valid but NOT cryptographically signed — it works only with +// modified PS5 firmware (jailbroken consoles running BackPork payload). + +const crypto = require('crypto'); + +function alignUp(x, a) { return (x + a - 1) & ~(a - 1); } +function ilog2(x) { return 31 - Math.clz32(x >>> 0); } + +// ── Constants ───────────────────────────────────────────────────────────────── + +// SELF magic — PS4 "fake signed" format used by BackPork +const SELF_MAGIC = Buffer.from([0x4F, 0x15, 0x3D, 0x1D]); +const SELF_VERSION = 0x00; +const SELF_MODE = 0x01; +const SELF_ENDIAN = 0x01; +const SELF_ATTRIBS = 0x12; +const SELF_KEY_TYPE = 0x101; +const SELF_FLAGS = 0x22; // 0x2 | (2 << 4) — signed_block_count=2 + +// Segment sizes +const BLOCK_SIZE = 0x4000; // 16 KiB blocks for signed entries +const DIGEST_SIZE = 0x20; // SHA-256 +const SIGNATURE_SIZE = 0x100; // empty for fake signing + +// ExInfo program type +const PTYPE_FAKE = 0x1; + +// ELF segment types that get SELF entries +const PT_LOAD = 0x1; +const PT_SCE_RELRO = 0x61000010; +const PT_SCE_DYNLIBDATA = 0x61000000; +const PT_SCE_COMMENT = 0x6FFFFF00; +const PT_SCE_VERSION = 0x6FFFFF01; +const LOADABLE = new Set([PT_LOAD, PT_SCE_RELRO, PT_SCE_DYNLIBDATA, PT_SCE_COMMENT]); + +// ELF header field offsets +const ELF_PHOFF_OFF = 0x20; +const ELF_EHSIZE_OFF = 0x34; +const ELF_PHENTSIZE_OFF = 0x36; +const ELF_PHNUM_OFF = 0x38; + +// ELF phdr field offsets (within 56-byte phdr) +const PHDR_TYPE_OFF = 0; +const PHDR_FILESZ_OFF = 32; +const PHDR_OFFSET_OFF = 8; + +/** + * Convert an unsigned ELF Buffer to a fake-signed SELF Buffer. + * + * @param {Buffer} elfBuf — raw ELF file content + * @param {object} [opts] + * @param {bigint|number} [opts.paid=0x3100000000000002n] — Program Auth ID + * @param {number} [opts.ptype=PTYPE_FAKE] — program type + * @returns {Buffer} the fake-signed SELF + */ +function makeFself(elfBuf, opts = {}) { + const paidRaw = opts.paid ?? 0x3100000000000002n; + const paid = typeof paidRaw === 'bigint' ? paidRaw : BigInt(paidRaw); + const ptype = opts.ptype ?? PTYPE_FAKE; + + // ── Parse ELF ──────────────────────────────────────────────────────────── + const ehsize = elfBuf.readUInt16LE(ELF_EHSIZE_OFF); + const phentsize = elfBuf.readUInt16LE(ELF_PHENTSIZE_OFF); + const phnum = elfBuf.readUInt16LE(ELF_PHNUM_OFF); + const phoff = Number(elfBuf.readBigUInt64LE(ELF_PHOFF_OFF)); + + const phdrs = []; + const segments = []; + let versionData = null; + + for (let i = 0; i < phnum; i++) { + const base = phoff + i * phentsize; + const type = elfBuf.readUInt32LE(base + PHDR_TYPE_OFF); + const off = Number(elfBuf.readBigUInt64LE(base + PHDR_OFFSET_OFF)); + const filesz = Number(elfBuf.readBigUInt64LE(base + PHDR_FILESZ_OFF)); + phdrs.push({ type, offset: off, filesz }); + const seg = filesz > 0 ? elfBuf.slice(off, off + filesz) : Buffer.alloc(0); + segments.push(seg); + if (type === PT_SCE_VERSION) versionData = seg; + } + + const elfDigest = crypto.createHash('sha256').update(elfBuf).digest(); + + // ── Build entry list (2 entries per loadable segment) ──────────────────── + // meta_entry: signed + has_digests, segment_index = data_entry index + // data_entry: signed + has_blocks + block_size, segment_index = ELF phdr index + const BLOCK_SIZE_SHIFT_VAL = ilog2(BLOCK_SIZE) - 12; // = 2 for 0x4000 + + const loadableIdxs = phdrs.reduce((acc, ph, i) => { + if (LOADABLE.has(ph.type)) acc.push(i); + return acc; + }, []); + + const numEntries = loadableIdxs.length * 2; + + // ── Size calculations ───────────────────────────────────────────────────── + // header_size = align16(common(8) + ext(24) + entries(n*32) + max(ehsize, phHdrEnd)) + exInfo(64) + npdrm(48) + const elfHdrAreaSize = Math.max(ehsize, phoff + phentsize * phnum); + const headerSize = alignUp(8 + 24 + numEntries * 32 + elfHdrAreaSize, 16) + 64 + 48; + // meta_size = entries*80(MetaBlocks) + 80(MetaFooter) + 256(Signature) + const metaSize = numEntries * 80 + 80 + SIGNATURE_SIZE; + + // ── Assign offsets for data / digest blocks ─────────────────────────────── + const entryDefs = []; // { props, offset, filesz, memsz, data } + let segOffset = headerSize + metaSize; + let entryIdx = 0; + + for (const phdrIdx of loadableIdxs) { + const ph = phdrs[phdrIdx]; + + // Meta entry (digest placeholder, one DIGEST_SIZE chunk per block) + const numBlocks = Math.max(1, Math.ceil(ph.filesz / BLOCK_SIZE)); + const metaData = Buffer.alloc(numBlocks * DIGEST_SIZE, 0); + let metaProps = 0n; + metaProps |= 0x4n; // signed + metaProps |= 0x10000n; // has_digests + metaProps |= BigInt(entryIdx + 1) << 20n; // segment_index → next (data) entry + entryDefs.push({ props: metaProps, offset: segOffset, filesz: metaData.length, memsz: metaData.length, data: metaData }); + segOffset = alignUp(segOffset + metaData.length, 16); + + // Data entry (actual segment bytes) + const segData = segments[phdrIdx]; + let dataProps = 0n; + dataProps |= 0x4n; // signed + dataProps |= 0x800n; // has_blocks + dataProps |= BigInt(BLOCK_SIZE_SHIFT_VAL) << 12n; // block_size field + dataProps |= BigInt(phdrIdx) << 20n; // segment_index → ELF phdr + entryDefs.push({ props: dataProps, offset: segOffset, filesz: ph.filesz, memsz: ph.filesz, data: segData }); + segOffset = alignUp(segOffset + ph.filesz, 16); + + entryIdx += 2; + } + + const fileSize = segOffset; + const totalSize = fileSize + (versionData ? versionData.length : 0); + const out = Buffer.alloc(totalSize, 0); + + // ── Write common header (8 bytes) ───────────────────────────────────────── + let pos = 0; + SELF_MAGIC.copy(out, pos); pos += 4; + out[pos++] = SELF_VERSION; + out[pos++] = SELF_MODE; + out[pos++] = SELF_ENDIAN; + out[pos++] = SELF_ATTRIBS; + + // ── Write extended header (24 bytes) ───────────────────────────────────── + out.writeUInt32LE(SELF_KEY_TYPE, pos); pos += 4; + out.writeUInt16LE(headerSize, pos); pos += 2; + out.writeUInt16LE(metaSize, pos); pos += 2; + out.writeBigUInt64LE(BigInt(fileSize), pos); pos += 8; + out.writeUInt16LE(numEntries, pos); pos += 2; + out.writeUInt16LE(SELF_FLAGS, pos); pos += 2; + pos += 4; // padding — stays zero + + // ── Write entries (numEntries × 32 bytes) ───────────────────────────────── + for (const e of entryDefs) { + out.writeBigUInt64LE(e.props, pos); pos += 8; + out.writeBigUInt64LE(BigInt(e.offset), pos); pos += 8; + out.writeBigUInt64LE(BigInt(e.filesz), pos); pos += 8; + out.writeBigUInt64LE(BigInt(e.memsz), pos); pos += 8; + } + + // ── Write ELF headers (elfHdrAreaSize bytes, then align to 16) ─────────── + const elfHdrStart = pos; + elfBuf.copy(out, pos, 0, elfHdrAreaSize); + pos = alignUp(elfHdrStart + elfHdrAreaSize, 16); + + // ── Write ExInfo (64 bytes) ─────────────────────────────────────────────── + out.writeBigUInt64LE(paid, pos); pos += 8; + out.writeBigUInt64LE(BigInt(ptype), pos); pos += 8; + out.writeBigUInt64LE(0n, pos); pos += 8; // app_version + out.writeBigUInt64LE(0n, pos); pos += 8; // fw_version + elfDigest.copy(out, pos); pos += 32; + + // ── Write NPDRM control block (48 bytes) ────────────────────────────────── + // type(2) + pad(14) + content_id(19, zeros) + random_pad(13, zeros) + out.writeUInt16LE(0x3, pos); pos += 2; + pos += 14; // padding + pos += 19; // content_id (zeros) + pos += 13; // random_pad (zeros) + // pos should now == headerSize + + // ── Write meta blocks (numEntries × 80 zeros) ──────────────────────────── + pos += numEntries * 80; + + // ── Write meta footer (80 bytes): 48 zeros + uint32(0x10000) + 28 zeros ── + pos += 48; + out.writeUInt32LE(0x10000, pos); pos += 4; + pos += 28; + + // ── Signature (256 zeros — already zero from Buffer.alloc) ─────────────── + pos += SIGNATURE_SIZE; + // pos should now == headerSize + metaSize + + // ── Write segment data at their computed offsets ────────────────────────── + for (const e of entryDefs) { + if (e.data && e.data.length > 0) { + e.data.copy(out, e.offset, 0, e.filesz); + } + } + + // ── Append PT_SCE_VERSION data after fileSize ───────────────────────────── + if (versionData && versionData.length > 0) { + versionData.copy(out, fileSize); + } + + return out; +} + +module.exports = { makeFself, PTYPE_FAKE }; diff --git a/src/backpork-gen/processor-worker.js b/src/backpork-gen/processor-worker.js new file mode 100644 index 0000000..ec896e5 --- /dev/null +++ b/src/backpork-gen/processor-worker.js @@ -0,0 +1,35 @@ +'use strict'; + +// ── Processor Worker ─────────────────────────────────────────────────────────── +// Runs processGame entirely off the main thread. +// Communication via worker_threads message passing: +// Main → Worker: 'cancel' +// Worker → Main: { type: 'progress', data } +// { type: 'done', result } +// { type: 'error', message } + +const { workerData, parentPort } = require('worker_threads'); +const { processGame } = require('./processor'); + +// Catch any unhandled crash and report it back rather than silently hanging. +process.on('uncaughtException', err => parentPort.postMessage({ type: 'error', message: `Uncaught: ${err.message}` })); +process.on('unhandledRejection', err => parentPort.postMessage({ type: 'error', message: `Unhandled rejection: ${err?.message ?? err}` })); + +let cancelled = false; + +parentPort.on('message', msg => { + if (msg === 'cancel') cancelled = true; +}); + +const { sourceDir, outputDir, sdkPair, fakelibDir } = workerData; + +processGame({ + sourceDir, + outputDir, + sdkPair, + fakelibDir: fakelibDir || null, + isCancelled: () => cancelled, + onProgress: data => parentPort.postMessage({ type: 'progress', data }), +}) + .then(result => parentPort.postMessage({ type: 'done', result })) + .catch(err => parentPort.postMessage({ type: 'error', message: err.message })); diff --git a/src/backpork-gen/processor.js b/src/backpork-gen/processor.js new file mode 100644 index 0000000..d210282 --- /dev/null +++ b/src/backpork-gen/processor.js @@ -0,0 +1,305 @@ +'use strict'; + +// ── Auto-Backpork Pipeline Processor ────────────────────────────────────────── +// +// 1:1 Node.js port of Auto-Backpork/Backport.py (NazkyYT). +// +// Files are identified by MAGIC BYTES (not extension), matching the original: +// \x7FELF → unsigned ELF → patch SDK → fake-sign → output SELF +// PS4/PS5 SELF magic → fake SELF → extract ELF → patch SDK → fake-sign → output SELF +// anything else → skipped entirely (no copy, no output) +// .bak files → always skipped +// +// Output folder contains ONLY the processed SELF files preserving relative paths, +// plus the fakelib directory if provided. +// The libc.prx patch is applied AFTER signing, on the SELF output files. + +const fs = require('fs'); +const fsp = require('fs').promises; +const path = require('path'); + +const { ELF_MAGIC, PS4_SELF_MAGIC, PS5_SELF_MAGIC, + patchElfBuffer, SDK_VERSION_PAIRS } = require('./sdk-patcher'); +const { extractElf } = require('./decrypt-fself'); +const { makeFself, PTYPE_FAKE } = require('./make-fself'); + +// ── libc.prx byte patch (SDK pairs 1-6 need it, 7-10 need it reverted) ─────── +const LIBC_PATTERN = Buffer.from('4h6F1LLbTiw#A#B', 'ascii'); +const LIBC_REPLACEMENT = Buffer.from('IWIBBdTHit4#A#B', 'ascii'); + +/** Apply or revert the libc.prx patch in a Buffer (returns new Buffer). */ +function applyLibcPatch(buf, apply = true) { + const from = apply ? LIBC_PATTERN : LIBC_REPLACEMENT; + const to = apply ? LIBC_REPLACEMENT : LIBC_PATTERN; + if (!buf.includes(from)) return null; // pattern not found + // Replace all occurrences + let result = buf; + let idx; + while ((idx = result.indexOf(from)) !== -1) { + const tmp = Buffer.from(result); + to.copy(tmp, idx); + result = tmp; + } + return result; +} + +/** Walk a directory recursively, yielding file paths. */ +function* walkDir(dir, skipDirs = new Set()) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const e of entries) { + const full = path.join(dir, e.name); + if (e.isDirectory()) { + if (!skipDirs.has(e.name.toLowerCase())) yield* walkDir(full, skipDirs); + } else { + yield full; + } + } +} + +// Extensions that PS5 ELF/SELF executables actually use. +// No-extension files are excluded — PS5 executables always have one of these. +// This avoids opening thousands of shader/data files (e.g. Returnal). +const CANDIDATE_EXTS = new Set(['.bin', '.elf', '.self', '.prx', '.sprx']); + +/** Determine whether a file is ELF or SELF from its first 4 bytes. + * Returns 'skip' immediately if the extension rules it out. */ +function detectType(filePath) { + const ext = path.extname(filePath).toLowerCase(); + if (!CANDIDATE_EXTS.has(ext)) return 'other'; + try { + const fd = fs.openSync(filePath, 'r'); + const buf = Buffer.alloc(4); + fs.readSync(fd, buf, 0, 4, 0); + fs.closeSync(fd); + if (buf.equals(ELF_MAGIC)) return 'elf'; + if (buf.equals(PS4_SELF_MAGIC)) return 'self'; + if (buf.equals(PS5_SELF_MAGIC)) return 'self'; + return 'other'; + } catch { + return 'other'; + } +} + +/** + * Process a game source folder through the Auto-Backpork pipeline. + * + * @param {object} opts + * @param {string} opts.sourceDir — game's existing backup folder (input) + * @param {string} opts.outputDir — where to write the processed game folder + * @param {number} opts.sdkPair — 1-10 + * @param {bigint|number} [opts.paid] — PAID (default 0x3100000000000002) + * @param {number} [opts.ptype] — program type (default PTYPE_FAKE) + * @param {string} [opts.fakelibDir] — path to fakelib/ SPRX folder (optional) + * @param {boolean} [opts.applyLibcPatch=true] + * @param {Function} [opts.onProgress] — ({ step, file, msg }) callback + * @param {Function} [opts.isCancelled] — () => boolean + * @returns {{ ok: number, failed: number, skipped: number, files: object[] }} + */ +async function processGame(opts) { + const { + sourceDir, + outputDir, + sdkPair, + paid = 0x3100000000000002n, + ptype = PTYPE_FAKE, + fakelibDir = null, + applyLibcPatch: doLibcPatch = true, + onProgress = () => {}, + isCancelled = () => false, + } = opts; + + if (!SDK_VERSION_PAIRS[sdkPair]) throw new Error(`Unknown SDK pair: ${sdkPair}`); + const [ps5Ver, ps4Ver] = SDK_VERSION_PAIRS[sdkPair]; + + const results = { ok: 0, failed: 0, skipped: 0, files: [] }; + + await fsp.mkdir(outputDir, { recursive: true }); + + // ── Walk ALL files; identify ELF/SELF by magic bytes (matching original) ── + // .bak / .esbak backup files and the 'decrypted' subfolder are always skipped. + const isBackupFile = f => f.endsWith('.bak') || f.endsWith('.esbak'); + + onProgress({ step: 'scan', file: '', msg: 'Scanning game folder…' }); + + // Walk files with candidate extensions only — skips thousands of + // .pak / .ucas / .utoc / shader files etc. without opening them at all. + // Emits periodic progress so large directories (e.g. Returnal) never look frozen. + const allFiles = []; + let scanned = 0; + for (const f of walkDir(sourceDir, new Set(['decrypted']))) { + if (isBackupFile(f)) { scanned++; continue; } + const ext = path.extname(f).toLowerCase(); + if (CANDIDATE_EXTS.has(ext)) allFiles.push(f); + scanned++; + if (scanned % 500 === 0) { + onProgress({ step: 'scan', file: '', msg: `Scanning… ${scanned.toLocaleString()} entries checked, ${allFiles.length} candidates so far` }); + } + } + + onProgress({ step: 'scan', file: '', msg: `Found ${allFiles.length} candidate file(s) in ${scanned.toLocaleString()} total entries, verifying magic bytes…` }); + + // Magic-byte check only the small candidate set (type cached — no double-open). + const elfSelfFiles = []; // [{ srcPath, fileType }] + for (const f of allFiles) { + const t = detectType(f); + if (t === 'elf' || t === 'self') elfSelfFiles.push({ srcPath: f, fileType: t }); + } + + onProgress({ step: 'inventory', totalFiles: elfSelfFiles.length, executableFiles: elfSelfFiles.length }); + + for (let fileIndex = 0; fileIndex < elfSelfFiles.length; fileIndex++) { + const { srcPath, fileType } = elfSelfFiles[fileIndex]; + if (isCancelled()) break; + + const rel = path.relative(sourceDir, srcPath); + const dstPath = path.join(outputDir, rel); + const progressBase = { file: rel, fileIndex: fileIndex + 1, totalFiles: elfSelfFiles.length }; + + // Non-fatal: if the file vanished/locked between scan and now, let the per-file + // try/catch below fail just this file instead of aborting the whole game. + let fileSizeMb = '?'; + try { fileSizeMb = (fs.statSync(srcPath).size / 1048576).toFixed(1); } catch {} + onProgress({ step: 'process', ...progressBase, msg: `Processing ${rel} (${fileType}, ${fileSizeMb} MB)`, fileType }); + + try { + await fsp.mkdir(path.dirname(dstPath), { recursive: true }); + + // 1. Get an ELF buffer — decrypt fake SELF wrapper if needed + let elfBuf; + if (fileType === 'self') { + onProgress({ step: 'decrypt', ...progressBase, msg: `Reading ${fileSizeMb} MB SELF…` }); + const selfBuf = await fsp.readFile(srcPath); + onProgress({ step: 'decrypt', ...progressBase, msg: 'Extracting ELF from SELF…' }); + elfBuf = extractElf(selfBuf); + } else { + onProgress({ step: 'patch', ...progressBase, msg: `Reading ${fileSizeMb} MB ELF…` }); + elfBuf = Buffer.from(await fsp.readFile(srcPath)); + } + + // 2. Patch SDK version fields in ELF (in-place on buffer) + onProgress({ step: 'patch', ...progressBase, msg: 'Patching SDK version…' }); + const patchResult = patchElfBuffer(elfBuf, ps5Ver, ps4Ver); + if (!patchResult.ok) { + onProgress({ step: 'warn', ...progressBase, msg: `SDK patch: ${patchResult.msg}` }); + results.files.push({ path: rel, status: 'warn', msg: patchResult.msg }); + } else { + onProgress({ step: 'patch', ...progressBase, msg: patchResult.msg }); + } + + // 3. Fake-sign patched ELF → SELF + onProgress({ step: 'sign', ...progressBase, msg: 'Creating fake-signed SELF…' }); + const selfOut = makeFself(elfBuf, { paid, ptype }); + onProgress({ step: 'sign', ...progressBase, msg: `Writing ${(selfOut.length / 1048576).toFixed(1)} MB output…` }); + await fsp.writeFile(dstPath, selfOut); + + results.ok++; + results.files.push({ path: rel, status: 'ok' }); + } catch (err) { + onProgress({ step: 'error', ...progressBase, msg: err.message }); + results.files.push({ path: rel, status: 'error', msg: err.message }); + results.failed++; + } + } + + if (isCancelled()) return results; + + // ── libc.prx patch / revert on SELF files in output ───────────────────── + if (doLibcPatch) { + const applyPatch = sdkPair <= 6; + // Identify SELF files in output by magic bytes — same approach as original + const libcTargets = [...walkDir(outputDir, new Set(['decrypted', 'sce_sys', 'fakelib']))] + .filter(outPath => !isBackupFile(outPath) && detectType(outPath) === 'self'); + onProgress({ + step: 'libc', + file: '', + msg: applyPatch ? 'Applying libc.prx patch…' : 'Reverting libc.prx patch (SDK > 6)…', + totalFiles: libcTargets.length, + }); + + for (let libcIndex = 0; libcIndex < libcTargets.length; libcIndex++) { + if (isCancelled()) break; + const outPath = libcTargets[libcIndex]; + + try { + const buf = await fsp.readFile(outPath); + const patched = applyLibcPatch(buf, applyPatch); + if (patched) { + await fsp.writeFile(outPath, patched); + onProgress({ + step: 'libc-progress', + file: path.relative(outputDir, outPath), + msg: applyPatch ? 'libc patch applied' : 'libc patch reverted', + fileIndex: libcIndex + 1, + totalFiles: libcTargets.length, + }); + } else { + onProgress({ + step: 'libc-progress', + file: path.relative(outputDir, outPath), + msg: 'No libc pattern found', + fileIndex: libcIndex + 1, + totalFiles: libcTargets.length, + }); + } + } catch { /* non-fatal */ } + } + } + + if (isCancelled()) return results; + + // ── Copy fakelib into output (1:1 with Nazky's _copy_fakelib + + // _copy_fakelib_to_eboot_dirs) ───────────────────────────────────────── + if (fakelibDir && fs.existsSync(fakelibDir)) { + onProgress({ step: 'fakelib', file: '', msg: 'Copying fakelib…' }); + + // Helper: copy fakelib dir to a destination path + async function copyFakelibTo(destDir) { + const files = [...walkDir(fakelibDir)]; + await fsp.mkdir(destDir, { recursive: true }); + for (const src of files) { + if (isCancelled()) break; + const rel = path.relative(fakelibDir, src); + const dst = path.join(destDir, rel); + await fsp.mkdir(path.dirname(dst), { recursive: true }); + await fsp.copyFile(src, dst); + } + } + + try { + // Step 1: copy fakelib to output root + await copyFakelibTo(path.join(outputDir, 'fakelib')); + onProgress({ step: 'fakelib', file: '', msg: 'fakelib copied to output root' }); + + // Step 2: also copy fakelib beside every eboot.bin in a subdirectory + // (matches Nazky's _copy_fakelib_to_eboot_dirs) + for (const outFile of walkDir(outputDir, new Set(['fakelib']))) { + if (path.basename(outFile).toLowerCase() !== 'eboot.bin') continue; + const ebootDir = path.dirname(outFile); + if (ebootDir === outputDir) continue; // root already handled above + if (isCancelled()) break; + try { + await copyFakelibTo(path.join(ebootDir, 'fakelib')); + onProgress({ step: 'fakelib', file: '', msg: `fakelib copied beside ${path.relative(outputDir, outFile)}` }); + } catch (err) { + onProgress({ step: 'warn', file: '', msg: `fakelib copy to ${path.relative(outputDir, ebootDir)} failed: ${err.message}` }); + } + } + } catch (err) { + onProgress({ step: 'warn', file: '', msg: `fakelib copy failed: ${err.message}` }); + } + } + + return results; +} + +function copyDirRecursive(src, dst) { + fs.mkdirSync(dst, { recursive: true }); + for (const e of fs.readdirSync(src, { withFileTypes: true })) { + const s = path.join(src, e.name); + const d = path.join(dst, e.name); + if (e.isDirectory()) copyDirRecursive(s, d); + else fs.copyFileSync(s, d); + } +} + +module.exports = { processGame, SDK_VERSION_PAIRS }; diff --git a/src/backpork-gen/sdk-patcher.js b/src/backpork-gen/sdk-patcher.js new file mode 100644 index 0000000..718e53b --- /dev/null +++ b/src/backpork-gen/sdk-patcher.js @@ -0,0 +1,122 @@ +'use strict'; + +// ── PS5 SDK Version Patcher ──────────────────────────────────────────────────── +// Ports Auto-Backpork/src/ps5_sdk_version_patcher.py (by idlesauce / NazkyYT) +// to pure Node.js Buffer operations — no Python required. + +const ELF_MAGIC = Buffer.from([0x7F, 0x45, 0x4C, 0x46]); // \x7FELF +const PS4_SELF_MAGIC = Buffer.from([0x4F, 0x15, 0x3D, 0x1D]); +const PS5_SELF_MAGIC = Buffer.from([0x54, 0x14, 0xF5, 0xEE]); + +// Built-in SDK version pairs { pair# → [ps5Ver, ps4Ver] } +// These are the 10 known pairs from the Auto-Backpork project. +// Additional pairs can be added via customSdkPairs in app settings. +const BUILTIN_SDK_VERSION_PAIRS = { + 1: [0x01000050, 0x07590001], + 2: [0x02000009, 0x08050001], + 3: [0x03000027, 0x08540001], + 4: [0x04000031, 0x09040001], + 5: [0x05000033, 0x09590001], + 6: [0x06000038, 0x10090001], + 7: [0x07000038, 0x10590001], + 8: [0x08000041, 0x11090001], + 9: [0x09000040, 0x11590001], + 10: [0x10000040, 0x12090001], +}; + +/** + * Merge built-in pairs with any user-defined custom pairs. + * customPairs format: { "11": [ps5VerHex, ps4VerHex], ... } + * Custom entries override built-ins for the same pair number. + */ +function buildSdkVersionPairs(customPairs = {}) { + const merged = { ...BUILTIN_SDK_VERSION_PAIRS }; + for (const [key, val] of Object.entries(customPairs)) { + const num = parseInt(key, 10); + if (!Number.isFinite(num) || num < 1) continue; + if (!Array.isArray(val) || val.length < 2) continue; + merged[num] = [Number(val[0]), Number(val[1])]; + } + return merged; +} + +// Default export uses only built-ins; callers that support custom pairs +// should call buildSdkVersionPairs(store.get('customSdkPairs', {})) instead. +const SDK_VERSION_PAIRS = BUILTIN_SDK_VERSION_PAIRS; + +// ELF program-header table locations (inside ELF header) +const PHT_OFFSET_OFFSET = 0x20; // uint64 LE — offset of PHT +const PHT_COUNT_OFFSET = 0x38; // uint16 LE — number of PHT entries + +// Per-entry offsets (each entry is 0x38 = 56 bytes) +const PHDR_ENTRY_SIZE = 0x38; +const PHDR_TYPE_OFF = 0x00; // uint32 LE +const PHDR_OFFSET_OFF = 0x08; // uint64 LE — file offset of segment data + +// SCE segment types that hold SDK version info +const PT_SCE_PROCPARAM = 0x61000001; +const PT_SCE_MODULE_PARAM = 0x61000002; + +// Offsets inside the SCE param struct (relative to segment file offset) +const SCE_PARAM_MAGIC_OFF = 0x08; // uint32 LE — magic sanity check +const SCE_PARAM_PS4_OFF = 0x10; // uint32 LE — PS4 SDK version +const SCE_PARAM_PS5_OFF = 0x14; // uint32 LE — PS5 SDK version + +const SCE_PROCPARAM_MAGIC = 0x4942524F; +const SCE_MODULE_PARAM_MAGIC = 0x3C13F4BF; + +// Supported file extensions for scanning +const EXECUTABLE_EXTS = new Set(['.bin', '.elf', '.self', '.prx', '.sprx']); + +/** + * Patch SDK version bytes directly inside an ELF Buffer (in-place). + * @param {Buffer} buf — mutable Buffer containing ELF data + * @param {number} ps5Ver — PS5 SDK version (uint32) + * @param {number} ps4Ver — PS4 SDK version (uint32) + * @returns {{ ok: boolean, msg: string }} + */ +function patchElfBuffer(buf, ps5Ver, ps4Ver) { + // Validate magic + if (!buf.slice(0, 4).equals(ELF_MAGIC)) { + if (buf.slice(0, 4).equals(PS4_SELF_MAGIC) || buf.slice(0, 4).equals(PS5_SELF_MAGIC)) { + return { ok: false, msg: 'Skipping SELF file — must be unsigned ELF' }; + } + return { ok: false, msg: 'Not an ELF file (bad magic)' }; + } + + const phtOffset = Number(buf.readBigUInt64LE(PHT_OFFSET_OFFSET)); + const phnum = buf.readUInt16LE(PHT_COUNT_OFFSET); + let patched = false; + + for (let i = 0; i < phnum; i++) { + const base = phtOffset + i * PHDR_ENTRY_SIZE; + const segType = buf.readUInt32LE(base + PHDR_TYPE_OFF); + + if (segType !== PT_SCE_PROCPARAM && segType !== PT_SCE_MODULE_PARAM) continue; + + const segOff = Number(buf.readBigUInt64LE(base + PHDR_OFFSET_OFF)); + const paramSz = buf.readUInt32LE(segOff); + + // Empty module param is OK — skip silently and keep looking for PROCPARAM + if (paramSz === 0 && segType === PT_SCE_MODULE_PARAM) continue; + + if (paramSz < SCE_PARAM_PS5_OFF + 4) { + return { ok: false, msg: `Unexpected param size 0x${paramSz.toString(16)}` }; + } + + const magic = buf.readUInt32LE(segOff + SCE_PARAM_MAGIC_OFF); + const expectedMagic = segType === PT_SCE_PROCPARAM ? SCE_PROCPARAM_MAGIC : SCE_MODULE_PARAM_MAGIC; + if (magic !== expectedMagic) { + return { ok: false, msg: `Invalid param magic 0x${magic.toString(16).padStart(8, '0')}` }; + } + + buf.writeUInt32LE(ps5Ver, segOff + SCE_PARAM_PS5_OFF); + buf.writeUInt32LE(ps4Ver, segOff + SCE_PARAM_PS4_OFF); + patched = true; + } + + if (!patched) return { ok: false, msg: 'No SCE param segment found' }; + return { ok: true, msg: 'SDK version patched' }; +} + +module.exports = { SDK_VERSION_PAIRS, BUILTIN_SDK_VERSION_PAIRS, buildSdkVersionPairs, patchElfBuffer, ELF_MAGIC, PS4_SELF_MAGIC, PS5_SELF_MAGIC, EXECUTABLE_EXTS }; diff --git a/src/bdjb-generator.js b/src/bdjb-generator.js new file mode 100644 index 0000000..544aab3 --- /dev/null +++ b/src/bdjb-generator.js @@ -0,0 +1,72 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +function validateRelativeName(name, label) { + if (!name || typeof name !== 'string' || name !== path.basename(name) || name.includes('..') || /[\\/\0]/.test(name)) { + throw new Error(`Invalid ${label}: ${name}`); + } + return name; +} + +function normalizeEntries(entries, payloadNames) { + const lines = []; + for (const entry of entries || []) { + if (!entry || !entry.type) continue; + if (entry.type === 'payload') { + const name = validateRelativeName(entry.value, 'payload name'); + if (!payloadNames.has(name)) throw new Error(`Autoloader references a payload that was not selected: ${name}`); + lines.push(name); + } else if (entry.type === 'delay') { + const value = String(entry.value || '').trim(); + if (!/^\d{1,7}$/.test(value)) throw new Error(`Delay must be a whole number of milliseconds: ${entry.value}`); + lines.push(`!${value}`); + } else { + throw new Error(`Unsupported BDJB autoloader entry type: ${entry.type}`); + } + } + if (!lines.some((line) => !line.startsWith('!'))) throw new Error('Add at least one payload to the BDJB autoloader sequence.'); + return `${lines.join('\n')}\n`; +} + +async function copyDirectory(source, target) { + await fs.promises.cp(source, target, { recursive: true, force: true, errorOnExist: false }); +} + +async function prepareBdjbWorkspace({ templateDir, workspaceDir, payloads, entries, discTitle, log = () => {} }) { + const required = ['BDMV', 'bin', 'payload.jar', 'ps5_autoloader', 'src', 'Makefile']; + for (const item of required) { + if (!fs.existsSync(path.join(templateDir, item))) throw new Error(`BDJB template is incomplete: missing ${item}`); + } + await fs.promises.rm(workspaceDir, { recursive: true, force: true }); + log('Copying the bundled Cyberpunk BDJB template.'); + await copyDirectory(templateDir, workspaceDir); + + const staged = new Map(); + for (const payload of payloads || []) { + const name = validateRelativeName(payload?.name, 'payload name'); + if (!payload?.path || !fs.existsSync(payload.path)) throw new Error(`Selected payload is unavailable: ${name}`); + if (!/\.(elf|bin)$/i.test(name)) throw new Error(`BDJB autoloader accepts ELF or BIN payloads: ${name}`); + if (staged.has(name)) throw new Error(`Duplicate payload filename: ${name}`); + staged.set(name, payload.path); + } + const autoload = normalizeEntries(entries, new Set(staged.keys())); + const destination = path.join(workspaceDir, 'ps5_autoloader'); + for (const [name, source] of staged) { + log(`Staging payload: ${name}`); + await fs.promises.copyFile(source, path.join(destination, name)); + } + await fs.promises.writeFile(path.join(destination, 'autoload.txt'), autoload, 'utf8'); + + const title = String(discTitle || 'Chimera BDJB').replace(/[<&>]/g, '').trim() || 'Chimera BDJB'; + const metadataPath = path.join(workspaceDir, 'BDMV', 'META', 'DL', 'bdmt_eng.xml'); + const metadata = await fs.promises.readFile(metadataPath, 'utf8'); + await fs.promises.writeFile(metadataPath, metadata + .replace(/[^<]*<\/di:name>/, `${title}`) + .replace(/[^<]*<\/di:titleName>/, `${title}`), 'utf8'); + log(`Wrote autoload sequence with ${autoload.trim().split('\n').length} entries.`); + return { workspaceDir, payloadCount: staged.size, autoload }; +} + +module.exports = { prepareBdjbWorkspace, normalizeEntries, validateRelativeName }; diff --git a/src/cheats.js b/src/cheats.js new file mode 100644 index 0000000..f6159dd --- /dev/null +++ b/src/cheats.js @@ -0,0 +1,71 @@ +'use strict'; + +const https = require('https'); +const http = require('http'); +const log = require('electron-log'); + +const INDEX_URL = 'https://raw.githubusercontent.com/TeeKay87/HEN-Cheats-Collection/master/cheats/json.txt'; +const FILE_BASE = 'https://raw.githubusercontent.com/TeeKay87/HEN-Cheats-Collection/master/cheats/json/'; + +function httpGet(url) { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const lib = parsed.protocol === 'https:' ? https : http; + const req = lib.request({ + hostname: parsed.hostname, + path: parsed.pathname + parsed.search, + method: 'GET', + headers: { 'User-Agent': 'Chimera/2.0' }, + }, res => { + const chunks = []; + res.on('data', c => chunks.push(c)); + res.on('end', () => { + if (res.statusCode >= 400) + return reject(new Error(`HTTP ${res.statusCode}: ${url}`)); + resolve(Buffer.concat(chunks).toString('utf8')); + }); + res.on('error', reject); + }); + req.on('error', reject); + req.setTimeout(20000, () => { req.destroy(new Error('Timeout')); }); + req.end(); + }); +} + +// Parse the json.txt index file into structured entries +async function fetchIndex() { + log.info('[Cheats] Fetching index…'); + const raw = await httpGet(INDEX_URL); + const entries = []; + for (const line of raw.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || !trimmed.includes('=')) continue; + const eq = trimmed.indexOf('='); + const filename = trimmed.slice(0, eq).trim(); + const title = trimmed.slice(eq + 1).trim(); + // Must be a .json file (case-insensitive) + if (!/\.json$/i.test(filename)) continue; + // Try to extract a CUSA-style ID (4 uppercase letters + 5 digits) from start + const m = filename.match(/^([A-Z]{4}\d{5})_(.+)\.json$/i); + const cusaId = m ? m[1].toUpperCase() : ''; + const version = m ? m[2] : filename.replace(/\.json$/i, ''); + entries.push({ filename, cusaId, version, title }); + } + log.info(`[Cheats] Index loaded — ${entries.length} entries`); + return entries; +} + +// Download a single cheat JSON file +async function fetchCheatFile(filename) { + const safe = String(filename || ''); + if (!/^[A-Z]{4}\d{5}_[A-Za-z0-9._-]+\.json$/i.test(safe)) throw new Error(`Invalid cheat filename: ${safe}`); + const url = FILE_BASE + encodeURIComponent(safe); + const body = await httpGet(url); + try { + return JSON.parse(body); + } catch (_) { + throw new Error(`Invalid JSON in cheat file: ${filename}`); + } +} + +module.exports = { fetchIndex, fetchCheatFile }; diff --git a/src/core/command-registry.js b/src/core/command-registry.js new file mode 100644 index 0000000..28282d1 --- /dev/null +++ b/src/core/command-registry.js @@ -0,0 +1,125 @@ +'use strict'; + +const CLASSIFICATIONS = new Set(['read-only', 'mutating', 'destructive', 'sensitive']); +const DEFAULT_BODY_LIMIT = 256 * 1024; +const MAX_BODY_LIMIT = 72 * 1024 * 1024; +const PERMISSIONS = new Set(['authenticated', 'expert', 'public']); +const EXPERT_MODE_CONFIRMATION = 'ENABLE EXPERT MODE'; +const EXPERT_MODE_DURATION_MS = 15 * 60 * 1000; +const EXPERT_MODE_LOGIN_WINDOW_MS = 10 * 60 * 1000; +const EXPERT_COMMAND_PATTERNS = Object.freeze([ + /^(?:database|shadowmount|packages|diagnostics|monitoring):/i, + /^ecosystem:(?:files|live|diagnostics):/i, + /^system:service-matrix$/i, + /^system-state:(?:deploy|command)$/i, + /^(?:power|disc):/i, + /^(?:system|console):(?:power|shutdown|restart|reboot|rest|eject)/i, +]); + +function inferredPermission(name, explicit = 'authenticated') { + if (explicit !== 'authenticated') return explicit; + return EXPERT_COMMAND_PATTERNS.some(pattern => pattern.test(String(name || ''))) ? 'expert' : explicit; +} + +function setSessionExpertMode(input, context, now = Date.now()) { + const session = context?.session; + if (!session) throw Object.assign(new Error('Authenticatie vereist.'), { statusCode: 401 }); + if (input?.enabled !== true) { + session.expertMode = false; + session.expertModeExpiresAt = null; + return { enabled: false, expiresAt: null, sessionId: session.id }; + } + if (session.role !== 'admin') throw Object.assign(new Error('Alleen de beheerder kan expert mode activeren.'), { statusCode: 403, code: 'EXPERT_ADMIN_REQUIRED' }); + if (!Number.isSafeInteger(session.authenticatedAt) || now - session.authenticatedAt > EXPERT_MODE_LOGIN_WINDOW_MS) { + throw Object.assign(new Error('Log opnieuw in voordat je expert mode activeert.'), { statusCode: 401, code: 'EXPERT_REAUTH_REQUIRED' }); + } + if (input.confirmation !== EXPERT_MODE_CONFIRMATION) { + throw Object.assign(new Error(`Typ ${EXPERT_MODE_CONFIRMATION} om expert mode te activeren.`), { statusCode: 400, code: 'EXPERT_CONFIRMATION_REQUIRED' }); + } + session.expertMode = true; + session.expertModeExpiresAt = now + EXPERT_MODE_DURATION_MS; + return { enabled: true, expiresAt: session.expertModeExpiresAt, sessionId: session.id }; +} +class CommandRegistry { + constructor({ platform = 'server', audit = () => {} } = {}) { + this.platform = platform; + this.audit = audit; + this.commands = new Map(); + this.telemetry = { invocations: 0, failures: 0, totalDurationMs: 0, durations: [] }; + } + + register(definition) { + const { name, handler, classification, confirmation, platforms = ['electron', 'server'], permission: declaredPermission = 'authenticated', maxBodyBytes } = definition; + const permission = inferredPermission(name, declaredPermission); + if (!/^[a-z0-9][a-z0-9:.-]{1,79}$/i.test(name || '')) throw new Error('Ongeldige commandnaam.'); + if (typeof handler !== 'function') throw new Error(`Command ${name} mist een handler.`); + if (!CLASSIFICATIONS.has(classification)) throw new Error(`Command ${name} heeft een ongeldige classificatie.`); + if (!PERMISSIONS.has(permission)) throw new Error(`Command ${name} heeft een ongeldige permission.`); + if (classification === 'destructive' && !((typeof confirmation === 'string' && confirmation.trim()) || typeof confirmation === 'function')) { + throw new Error(`Destructief command ${name} mist een exacte bevestiging.`); + } + if (maxBodyBytes !== undefined && (!Number.isInteger(maxBodyBytes) || maxBodyBytes < 1024 || maxBodyBytes > MAX_BODY_LIMIT)) { + throw new Error(`Command ${name} heeft een ongeldige requestlimiet.`); + } + if (this.commands.has(name)) throw new Error(`Dubbel command: ${name}`); + this.commands.set(name, { validate: input => input, audit: classification !== 'read-only', ...definition, platforms, permission }); + return this; + } + + describe() { + return [...this.commands.values()].map(({ handler, validate, ...definition }) => definition); + } + + isMutating(name) { + const definition = this.commands.get(name); + return definition ? definition.classification !== 'read-only' : false; + } + + requestBodyLimit(name) { + return this.commands.get(name)?.maxBodyBytes || DEFAULT_BODY_LIMIT; + } + + metrics() { + const durations = [...this.telemetry.durations].sort((a, b) => a - b); + const percentile = value => durations.length ? durations[Math.min(durations.length - 1, Math.floor(durations.length * value))] : 0; + return { + invocations: this.telemetry.invocations, + failures: this.telemetry.failures, + averageDurationMs: this.telemetry.invocations ? Math.round(this.telemetry.totalDurationMs / this.telemetry.invocations) : 0, + p95DurationMs: percentile(0.95), + sampleSize: durations.length, + }; + } + + async invoke(name, input, context = {}) { + const definition = this.commands.get(name); + if (!definition) throw Object.assign(new Error('Command is niet beschikbaar in servermodus.'), { statusCode: 404 }); + if (!definition.platforms.includes(this.platform)) throw Object.assign(new Error('Command is niet beschikbaar op dit platform.'), { statusCode: 409 }); + if (['authenticated', 'expert'].includes(definition.permission) && !context.session) throw Object.assign(new Error('Authenticatie vereist.'), { statusCode: 401 }); + if (definition.permission === 'expert' && context.session?.expertMode !== true) throw Object.assign(new Error('Expertmodus is vereist voor deze functie.'), { statusCode: 403, code: 'EXPERT_MODE_REQUIRED' }); + if (definition.classification === 'destructive') { + const confirmation = typeof definition.confirmation === 'function' ? definition.confirmation(input) : definition.confirmation; + if (typeof confirmation !== 'string' || !confirmation.trim()) throw new Error(`Command ${name} heeft geen geldige bevestiging.`); + if (!input || input.confirmation !== confirmation) throw Object.assign(new Error(`Typ ${confirmation} om deze actie te bevestigen.`), { statusCode: 400 }); + } + const validated = definition.validate(input); + const startedAt = Date.now(); + try { + const result = await definition.handler(validated, context); + if (definition.audit) this.audit({ command: name, classification: definition.classification, ok: true, sessionId: context.session?.id, requestIp: context.requestIp || null, durationMs: Date.now() - startedAt }); + return result; + } catch (error) { + this.telemetry.failures += 1; + if (definition.audit) this.audit({ command: name, classification: definition.classification, ok: false, sessionId: context.session?.id, requestIp: context.requestIp || null, error: error.message, durationMs: Date.now() - startedAt }); + throw error; + } finally { + const duration = Math.max(0, Date.now() - startedAt); + this.telemetry.invocations += 1; + this.telemetry.totalDurationMs += duration; + this.telemetry.durations.push(duration); + if (this.telemetry.durations.length > 256) this.telemetry.durations.shift(); + } + } +} + +module.exports = { CommandRegistry, CLASSIFICATIONS, DEFAULT_BODY_LIMIT, MAX_BODY_LIMIT, PERMISSIONS, EXPERT_COMMAND_PATTERNS, EXPERT_MODE_CONFIRMATION, EXPERT_MODE_DURATION_MS, EXPERT_MODE_LOGIN_WINDOW_MS, inferredPermission, setSessionExpertMode }; diff --git a/src/database.js b/src/database.js new file mode 100644 index 0000000..df2b47f --- /dev/null +++ b/src/database.js @@ -0,0 +1,1374 @@ +'use strict'; + +const path = require('path'); +const fs = require('fs'); +const crypto = require('crypto'); +const initSqlJs = require('sql.js'); +const { assertReadOnlySql } = require('./security/read-only-sql'); + +let db; // sql.js Database instance +let dbPath; // path to .db file on disk +let SQLRuntime; +let configuredDataDir = null; +let configuredLog = null; +let testMigrationFailureVersion = null; + +const migrationFiles = [ + '0001-create-core-schema.js', + '0002-migrate-legacy-columns.js', + '0003-create-v3-core.js', + '0004-migrate-legacy-games-to-v3.js', + '0005-create-semantic-evidence-overrides.js', + '0006-add-storage-evidence.js', + '0007-add-storage-friendly-label.js', + '0008-create-guided-game-moves.js', + '0009-create-package-sets.js', + '0010-create-install-recipes.js', + '0011-create-compatibility-findings.js', + '0012-create-trophy-psn-snapshots.js', + '0013-create-trophy-local-snapshots.js', + '0014-create-trophy-account-mappings.js', + '0015-create-trophy-reconstruction-plans.js', + '0016-create-trophy-reconstruction-journals.js', + '0017-create-ps5-system-backup-inventory.js', + '0018-create-save-recovery-plans.js', + '0019-add-v3-performance-indexes.js', + '0020-create-trophy-progression-plans.js', +]; + +const migrationTableName = 'schema_migrations'; + +function configure({ dataDir, logger } = {}) { + if (dataDir) configuredDataDir = path.resolve(dataDir); + if (logger) configuredLog = logger; +} + +function getLog() { + // Avoid initializing electron-log's desktop file transport in headless mode. + return configuredLog || require('electron-log'); +} + +function getDatabasePath() { + if (configuredDataDir) { + const target = path.join(configuredDataDir, 'chimera.db'); + const legacy = path.join(configuredDataDir, 'porkfolio.db'); + if (!fs.existsSync(target) && fs.existsSync(legacy)) fs.copyFileSync(legacy, target); + return target; + } + // Keep the existing Electron desktop behaviour without importing Electron in server mode. + const { app } = require('electron'); + const target = path.join(app.getPath('userData'), 'chimera.db'); + const legacy = path.join(app.getPath('userData'), 'porkfolio.db'); + if (!fs.existsSync(target) && fs.existsSync(legacy)) fs.copyFileSync(legacy, target); + return target; +} + +function atomicDatabaseWrite(data) { + const tmp = `${dbPath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tmp, Buffer.from(data)); + fs.renameSync(tmp, dbPath); +} + +// ── Persistence ─────────────────────────────────────────────────────────────── +// sql.js is in-memory. saveNow() flushes immediately; save() coalesces writes +// and bulkUpdate() lets scan-heavy callers defer the expensive flush until the +// whole batch finishes. +let _saveTimer = null; +let _bulkDepth = 0; +let _bulkDirty = false; +function save() { + if (_bulkDepth > 0) { + _bulkDirty = true; + return; + } + if (_saveTimer) return; // already scheduled + _saveTimer = setTimeout(() => { + _saveTimer = null; + // This runs from a timer context, so a throw here is an uncatchable crash. + try { + const data = db.export(); + atomicDatabaseWrite(data); + } catch (e) { + getLog().error('[DB] debounced save failed:', e); + } + }, 150); +} +function saveNow() { + if (_saveTimer) { clearTimeout(_saveTimer); _saveTimer = null; } + const data = db.export(); + atomicDatabaseWrite(data); +} + +function beginBulkUpdate() { + _bulkDepth++; +} + +function endBulkUpdate() { + if (_bulkDepth <= 0) return; + _bulkDepth--; + if (_bulkDepth === 0 && _bulkDirty) { + _bulkDirty = false; + saveNow(); + } +} + +function bulkUpdate(fn) { + beginBulkUpdate(); + try { + return fn(); + } finally { + endBulkUpdate(); + } +} + +function setTestMigrationFailureVersion(version) { + testMigrationFailureVersion = typeof version === 'number' ? version : null; +} + +function migrationFilePath(file) { + return path.join(__dirname, 'database', 'migrations', file); +} + +function loadMigrations() { + return migrationFiles.map((fileName) => { + const filePath = migrationFilePath(fileName); + const raw = fs.readFileSync(filePath, 'utf8'); + const migration = require(filePath); + if (typeof migration.version !== 'number') { + throw new Error(`[DB] migration invalid: ${fileName} has no numeric version`); + } + return { + version: migration.version, + description: migration.description || fileName, + checksum: crypto.createHash('sha256').update(raw).digest('hex'), + up: migration.up, + file: fileName, + path: filePath, + source: raw, + }; + }).sort((a, b) => a.version - b.version); +} + +function tableExists(name) { + return prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=@name").get({ name }) !== undefined; +} + +function hasColumn(table, column) { + if (!tableExists(table)) return false; + const sql = `PRAGMA table_info(${table})`; + const columns = prepare(sql).all(); + return columns.some((row) => row.name === column); +} + +function tableCount(name) { + if (!tableExists(name)) return 0; + return Number(prepare(`SELECT COUNT(*) AS count FROM ${name}`).get().count || 0); +} + +function tableCountsSnapshot() { + const tables = ['games', 'backups', 'backpork_folders', 'game_hashes', 'payload_sources', 'payload_files', 'cheat_files', 'automation_runs']; + const counts = {}; + for (const table of tables) counts[table] = tableCount(table); + return counts; +} + +function migrationLogPath(version) { + const dir = path.join(path.dirname(dbPath), 'chimera-migrations'); + fs.mkdirSync(dir, { recursive: true, mode: 0o750 }); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + return path.join(dir, `pre-migration-v${String(version).padStart(4, '0')}-${timestamp}.db`); +} + +function createMigrationSnapshot(version) { + if (!db) return null; + const snapshot = migrationLogPath(version); + fs.writeFileSync(snapshot, Buffer.from(db.export())); + return snapshot; +} + +function restoreMigrationSnapshot(snapshotPath) { + if (!snapshotPath || !fs.existsSync(snapshotPath)) return; + const restored = fs.readFileSync(snapshotPath); + atomicDatabaseWrite(restored); + const previous = db; + db = new SQLRuntime.Database(restored); + db.run('PRAGMA foreign_keys = ON;'); + try { previous?.close(); } catch (_) {} +} + +function readMigrationRecords() { + if (!tableExists(migrationTableName)) return []; + return prepare(`SELECT version, checksum, status, error, snapshot_path, recovery_instructions + FROM ${migrationTableName}`).all(); +} + +function recordMigration(version, migration, status, details) { + if (!tableExists(migrationTableName)) return; + const before = JSON.stringify(details.beforeCounts || {}); + const after = JSON.stringify(details.afterCounts || {}); + const delta = JSON.stringify({ + before, + after, + diff: details.afterCounts + ? Object.keys(details.afterCounts).reduce((acc, table) => { + const beforeCount = (details.beforeCounts || {})[table] || 0; + const afterCount = details.afterCounts[table] || 0; + acc[table] = afterCount - beforeCount; + return acc; + }, {}) + : {}, + }); + const statement = ` + INSERT INTO ${migrationTableName} (version, checksum, description, status, applied_at, error, snapshot_path, recovery_instructions, row_count_delta_json) + VALUES (@version, @checksum, @description, @status, @applied_at, @error, @snapshot_path, @recovery_instructions, @row_count_delta_json) + ON CONFLICT(version) DO UPDATE SET + checksum = excluded.checksum, + description = excluded.description, + status = excluded.status, + applied_at = excluded.applied_at, + error = excluded.error, + snapshot_path = excluded.snapshot_path, + recovery_instructions = excluded.recovery_instructions, + row_count_delta_json = excluded.row_count_delta_json + `; + prepare(statement).run({ + version, + checksum: migration.checksum, + description: migration.description, + status, + applied_at: Date.now(), + error: details.error || null, + snapshot_path: details.snapshotPath || null, + recovery_instructions: details.recoveryInstructions || null, + row_count_delta_json: delta, + }); +} + +function migrationHasBeenApplied(version, migration, migrationRows) { + const row = migrationRows.find((entry) => entry.version === version); + if (!row || row.status !== 'applied') return false; + if (row.checksum !== migration.checksum) { + throw new Error(`[DB] migration checksum mismatch for version ${version}`); + } + return true; +} + +function assertIntegrity() { + const integrity = db.exec('PRAGMA integrity_check;')?.[0]?.values?.[0]?.[0]; + if (integrity !== 'ok') { + throw new Error(`Database-integriteitscontrole gefaald: ${integrity || 'unknown'}`); + } + const fkRows = db.exec('PRAGMA foreign_key_check;')?.[0]?.values || []; + if (fkRows.length > 0) { + throw new Error(`Foreign-key check faalde voor ${fkRows.length} relatie(s)`); + } +} + +function runMigrations() { + const migrations = loadMigrations(); + const migrationRows = readMigrationRecords(); + const pending = migrations.filter((migration) => !migrationHasBeenApplied(migration.version, migration, migrationRows)); + if (pending.length === 0) return; + + let snapshot = createMigrationSnapshot(pending[0].version); + for (const migration of pending) { + const beforeCounts = tableCountsSnapshot(); + try { + if (testMigrationFailureVersion === migration.version) { + throw new Error(`Test-gedwongen migratiefout voor versie ${migration.version}`); + } + + db.run('BEGIN;'); + migration.up(db); + db.run('COMMIT;'); + + const afterCounts = tableCountsSnapshot(); + if (!migrationHasBeenApplied(migration.version, migration, readMigrationRecords())) { + recordMigration(migration.version, migration, 'applied', { + beforeCounts, + afterCounts, + snapshotPath: snapshot, + recoveryInstructions: snapshot + ? `Herstel door snapshot terug te zetten: ${snapshot}` + : 'Geen snapshot beschikbaar; deze migratie startte op een nieuwe databank.', + }); + } + snapshot = createMigrationSnapshot(migration.version + 1); + } catch (error) { + try { db.run('ROLLBACK;'); } catch (_) {} + if (snapshot) restoreMigrationSnapshot(snapshot); + const recoveryInstructions = snapshot + ? `Herstel door snapshot terug te zetten: ${snapshot}` + : 'Geen pre-migratiesnapshot beschikbaar; maak nu een handmatige backup voor een herstart.'; + recordMigration(migration.version, migration, 'failed', { + beforeCounts: tableCountsSnapshot(), + snapshotPath: snapshot, + recoveryInstructions, + error: String(error && error.message ? error.message : error), + }); + throw error; + } + } + + assertIntegrity(); +} + +// ── Parameter adapter ───────────────────────────────────────────────────────── +// better-sqlite3 callers pass plain {key: val} with @key in SQL. +// sql.js needs the prefix in the object key too: {"@key": val}. +function sqlParams(p) { + if (!p) return []; + if (Array.isArray(p)) return p; + const out = {}; + for (const [k, v] of Object.entries(p)) { + out[/^[@:$]/.test(k) ? k : '@' + k] = v ?? null; + } + return out; +} + +// ── Statement wrapper ───────────────────────────────────────────────────────── +// Provides the same .run() / .all() / .get() API as better-sqlite3. +class Stmt { + constructor(raw) { this._raw = raw; } + + run(params) { + this._raw.run(sqlParams(params)); + this._raw.free(); + save(); + return {}; + } + + all(params) { + if (params !== undefined) this._raw.bind(sqlParams(params)); + const rows = []; + while (this._raw.step()) rows.push(this._raw.getAsObject()); + this._raw.free(); + return rows; + } + + get(params) { + if (params !== undefined) this._raw.bind(sqlParams(params)); + const row = this._raw.step() ? this._raw.getAsObject() : undefined; + this._raw.free(); + return row; + } +} + +function prepare(sql) { + return new Stmt(db.prepare(sql)); +} + +// ── Init ────────────────────────────────────────────────────────────────────── +async function initialize() { + dbPath = getDatabasePath(); + fs.mkdirSync(path.dirname(dbPath), { recursive: true, mode: 0o750 }); + getLog().info('[DB] Opening:', dbPath); + + // Locate the sql.js WASM file next to the JS bundle + const sqlJsDist = path.dirname(require.resolve('sql.js')); + const SQL = await initSqlJs({ + locateFile: file => path.join(sqlJsDist, file), + }); + SQLRuntime = SQL; + + const buf = fs.existsSync(dbPath) ? fs.readFileSync(dbPath) : null; + db = buf ? new SQL.Database(buf) : new SQL.Database(); + + db.run('PRAGMA foreign_keys = ON;'); + runMigrations(); + save(); + getLog().info('[DB] Ready'); +} + +// ── Games ───────────────────────────────────────────────────────────────────── +function listLibraryTitles(filter = {}) { + const { createV3LibraryService } = require('./services/v3-library-service'); + return createV3LibraryService({ runQuery }).list(filter); +} + +function listGames(filter = {}) { + const { createV3LibraryService } = require('./services/v3-library-service'); + return createV3LibraryService({ runQuery }).listLegacyGames(filter); +} + +function listGamesLegacySql(filter = {}) { + // Use pre-aggregated sub-selects joined once rather than correlated subqueries + // that re-execute for every row. With indexes this is O(n) not O(n²). + let sql = ` + SELECT g.*, + COALESCE(lc.cnt, 0) AS backup_count, + COALESCE(lc.total_size, 0) AS backup_size, + COALESCE(bc.cnt, 0) AS backpork_count, + fw.firmware_labels, + COALESCE(cc.cnt, 0) AS cheat_count + FROM games g + LEFT JOIN ( + SELECT game_id, COUNT(*) AS cnt, SUM(size) AS total_size + FROM backups WHERE source = 'local' GROUP BY game_id + ) lc ON lc.game_id = g.game_id + LEFT JOIN ( + SELECT game_id, COUNT(*) AS cnt + FROM backups WHERE source = 'backpork' GROUP BY game_id + ) bc ON bc.game_id = g.game_id + LEFT JOIN ( + SELECT game_id, GROUP_CONCAT(folder_name, ',') AS firmware_labels + FROM game_firmware GROUP BY game_id + ) fw ON fw.game_id = g.game_id + LEFT JOIN ( + SELECT cusa_id, COUNT(*) AS cnt + FROM cheat_files GROUP BY cusa_id + ) cc ON cc.cusa_id = g.game_id`; + const params = []; + const conds = []; + + if (filter.installed !== undefined) { + conds.push('g.installed = ?'); + params.push(filter.installed ? 1 : 0); + } + if (filter.backed_up !== undefined) { + conds.push('g.backed_up = ?'); + params.push(filter.backed_up ? 1 : 0); + } + const platform = String(filter.platform || '').toLowerCase(); + if (platform === 'ps4') { + conds.push("UPPER(g.game_id) LIKE 'CUSA%'"); + } else if (platform === 'ps5') { + conds.push("UPPER(g.game_id) LIKE 'PPSA%'"); + } else if (platform === 'unknown') { + conds.push("UPPER(g.game_id) NOT LIKE 'CUSA%' AND UPPER(g.game_id) NOT LIKE 'PPSA%'"); + } + if (filter.search) { + conds.push('(g.title LIKE ? OR g.game_id LIKE ? OR g.prospero_name LIKE ? OR g.prospero_publisher LIKE ?)'); + params.push(`%${filter.search}%`, `%${filter.search}%`, `%${filter.search}%`, `%${filter.search}%`); + } + + if (conds.length) sql += ' WHERE ' + conds.join(' AND '); + sql += " ORDER BY COALESCE(NULLIF(g.prospero_name, ''), NULLIF(g.title, ''), g.game_id) COLLATE NOCASE ASC"; + if (filter.limit !== undefined) { + const limit = Math.max(1, Math.min(1000, Number(filter.limit) || 100)); + sql += ' LIMIT ?'; + params.push(limit); + } + + return prepare(sql).all(params.length ? params : undefined); +} + +function upsertGame(game) { + const result = prepare(` + INSERT INTO games (title, game_id, content_id, size, ftp_path, last_scanned) + VALUES (@title, @game_id, @content_id, @size, @ftp_path, @last_scanned) + ON CONFLICT(game_id) DO UPDATE SET + title = CASE WHEN excluded.title != '' THEN excluded.title ELSE games.title END, + content_id = excluded.content_id, + size = excluded.size, + ftp_path = excluded.ftp_path, + last_scanned = excluded.last_scanned + `).run(game); + const externalTitleId = String(game.game_id || '').trim().toUpperCase(); + const platform = externalTitleId.startsWith('CUSA') ? 'ps4' : externalTitleId.startsWith('PPSA') ? 'ps5' : 'unknown'; + const canonical = { + platform, + titleId: externalTitleId, + contentId: String(game.content_id || ''), + name: String(game.title || externalTitleId), + }; + runMutatingSqlWithParams(` + INSERT INTO titles (platform, title_id, content_id, name, sort_name, metadata_source) + SELECT @platform, @titleId, @contentId, @name, @name, 'legacy-transition' + WHERE @titleId != '' + AND NOT EXISTS ( + SELECT 1 FROM titles WHERE platform = @platform AND title_id = @titleId + ) + `, canonical); + runMutatingSqlWithParams(` + UPDATE titles + SET content_id = CASE WHEN @contentId != '' THEN @contentId ELSE content_id END, + name = CASE WHEN @name != '' AND @name != @titleId THEN @name ELSE name END, + sort_name = CASE WHEN @name != '' AND @name != @titleId THEN @name ELSE sort_name END, + updated_at = datetime('now') + WHERE platform = @platform AND title_id = @titleId + `, canonical); + return result; +} + +function updateGame(game_id, fields) { + const forbidden = Object.keys(fields || {}).filter(key => ['version', 'installed', 'backed_up'].includes(key)); + if (forbidden.length) throw new Error(`Legacy semantic fields are read-only: ${forbidden.join(', ')}. Record evidence instead.`); + const allowed = new Set([ + 'title', 'content_id', 'size', 'ftp_path', 'last_scanned', + 'porked_folder', 'porked_firmware', 'porked_at', + 'prospero_name', 'prospero_publisher', 'prospero_publisher_id', 'prospero_icon_url', + 'prospero_banner_url', 'prospero_cover_url', 'cover_source', 'cover_source_url', 'cover_fetched_at', + 'prospero_region', 'prospero_last_updated', 'prospero_patch_count', + 'prospero_fetched_at', 'prospero_patches', 'prospero_dlc', 'prospero_other_regions', + 'prospero_description', 'prospero_content_id', 'prospero_version', 'prospero_size', + 'metadata_failure_count', 'metadata_last_error', 'metadata_retry_at', 'metadata_source', + 'metadata_confidence', 'metadata_override', + ]); + const keys = Object.keys(fields || {}); + if (!keys.length) throw new Error('Geen gamevelden opgegeven.'); + const invalid = keys.filter(key => !allowed.has(key)); + if (invalid.length) throw new Error(`Niet-toegestane gamevelden: ${invalid.join(', ')}`); + const cols = keys.map(k => `${k} = @${k}`).join(', '); + return prepare(`UPDATE games SET ${cols} WHERE game_id = @game_id`).run({ ...fields, game_id }); +} + +function getGame(game_id) { + return prepare(` + SELECT g.*, + (SELECT COUNT(*) FROM backups b WHERE b.game_id = g.game_id AND b.source = 'local') as backup_count, + (SELECT SUM(b.size) FROM backups b WHERE b.game_id = g.game_id AND b.source = 'local') as backup_size + FROM games g WHERE g.game_id = @game_id + `).get({ game_id }); +} + +function updateProsperoData(game_id, data) { + const patches = data.patches || []; + const latestPatch = patches.find(p => p.isLatest) || patches[patches.length - 1] || null; + const requestedCoverSource = String(data.coverSource || ''); + const retiredCoverSource = requestedCoverSource === 'steamgriddb' + || ['wikipedia', 'wikipedia-fallback', 'wikipedia-portrait-v2'].includes(requestedCoverSource); + const retiredCoverLabel = requestedCoverSource === 'steamgriddb' ? 'steamgriddb-retired' : 'wikipedia-retired'; + return prepare(` + UPDATE games SET + title = CASE WHEN COALESCE(title, '') = '' AND COALESCE(@name, '') != '' THEN @name ELSE title END, + prospero_name = @name, + prospero_publisher = @publisher, + prospero_publisher_id = @publisherId, + prospero_icon_url = CASE WHEN COALESCE(@iconUrl, '') != '' THEN @iconUrl ELSE prospero_icon_url END, + prospero_banner_url = CASE WHEN COALESCE(@bannerUrl, '') != '' THEN @bannerUrl ELSE prospero_banner_url END, + prospero_cover_url = CASE WHEN @retiredCoverSource = 1 THEN NULL WHEN COALESCE(@coverUrl, '') != '' THEN @coverUrl ELSE prospero_cover_url END, + cover_source = CASE WHEN @retiredCoverSource = 1 THEN @retiredCoverLabel WHEN COALESCE(@coverSource, '') != '' THEN @coverSource ELSE cover_source END, + cover_source_url = CASE WHEN @retiredCoverSource = 1 THEN NULL WHEN COALESCE(@coverSourceUrl, '') != '' THEN @coverSourceUrl ELSE cover_source_url END, + cover_fetched_at = @coverFetchedAt, + prospero_region = @region, + prospero_last_updated = @lastUpdated, + prospero_patch_count = @patchCount, + prospero_fetched_at = @fetchedAt, + prospero_patches = @patches, + prospero_dlc = @dlc, + prospero_other_regions = @otherRegions, + prospero_description = @description, + prospero_content_id = @contentId, + prospero_version = @prosperoVersion, + prospero_size = @prosperoSize, + metadata_failure_count = 0, + metadata_last_error = NULL, + metadata_retry_at = NULL, + metadata_source = 'online', + metadata_confidence = 'provider' + WHERE game_id = @game_id + `).run({ + game_id, + name: data.name || '', + publisher: data.publisher || '', + publisherId: data.publisherId || '', + iconUrl: data.iconUrl || '', + bannerUrl: data.bannerUrl || '', + coverUrl: retiredCoverSource ? '' : (data.coverUrl || ''), + coverSource: retiredCoverSource ? retiredCoverLabel : requestedCoverSource, + coverSourceUrl: retiredCoverSource ? '' : (data.coverSourceUrl || ''), + coverFetchedAt: retiredCoverSource || data.coverFetchedAt == null ? null : Number(data.coverFetchedAt), + retiredCoverSource: retiredCoverSource ? 1 : 0, + retiredCoverLabel, + region: data.region || '', + lastUpdated: data.lastUpdated || '', + patchCount: data.patchCount || 0, + fetchedAt: data.fetchedAt || Date.now(), + patches: JSON.stringify(patches), + dlc: JSON.stringify(data.additionalContent || []), + otherRegions: JSON.stringify(data.otherRegions || []), + description: data.description || '', + contentId: data.contentId || '', + prosperoVersion: latestPatch?.contentVer || '', + prosperoSize: latestPatch?.filesize || '', + }); +} + +function getProsperoAge(game_id) { + const row = prepare('SELECT prospero_fetched_at FROM games WHERE game_id = @game_id').get({ game_id }); + if (!row || !row.prospero_fetched_at) return Infinity; + return Date.now() - row.prospero_fetched_at; +} + +// ── Backups ─────────────────────────────────────────────────────────────────── +function listBackups(game_id = null) { + if (game_id) { + return prepare('SELECT * FROM backups WHERE game_id = @game_id ORDER BY created_at DESC').all({ game_id }); + } + return prepare(` + SELECT b.*, g.title, g.prospero_name FROM backups b + LEFT JOIN games g ON b.game_id = g.game_id + ORDER BY b.created_at DESC + `).all(); +} + +function upsertBackup({ game_id, backup_path, size, backup_type, source = 'local' }) { + return prepare(` + INSERT INTO backups (game_id, backup_path, size, backup_type, source) + VALUES (@game_id, @backup_path, @size, @backup_type, @source) + ON CONFLICT(backup_path) DO UPDATE SET size = excluded.size, source = excluded.source + `).run({ game_id, backup_path, size, backup_type, source }); +} + +function deleteBackup(id) { + return prepare('DELETE FROM backups WHERE id = @id').run({ id }); +} + +// Remove any backup rows whose path ends with a known archive extension +function purgeArchiveBackups() { + const exts = ['.rar', '.zip', '.7z', '.tar', '.gz', '.bz2', '.xz', '.zst', '.001', '.002', '.003']; + const conds = exts.map(e => `backup_path LIKE '%${e}'`).join(' OR '); + return prepare(`DELETE FROM backups WHERE ${conds}`).run(); +} + +function clearBackupsForGame(game_id) { + return prepare('DELETE FROM backups WHERE game_id = @game_id').run({ game_id }); +} + +// Delete backup rows whose path is under folderRoot but NOT in keepPaths. +// Returns the number of rows deleted so the caller can log it. +function deleteStaleBackupsInFolder(folderRoot, keepPaths) { + const all = prepare('SELECT id, backup_path FROM backups').all(); + const toDelete = all.filter( + b => b.backup_path.startsWith(folderRoot) && !keepPaths.has(b.backup_path) + ); + for (const b of toDelete) { + prepare('DELETE FROM backups WHERE id = @id').run({ id: b.id }); + } + if (toDelete.length > 0) save(); + return toDelete.length; +} + +// Set backed_up = 0 for any game that no longer has any backup row. +function syncBackedUpFlags() { + return { changes: 0, derivedFrom: 'local_copies' }; +} + +// Remove game rows that have no backup entries and no firmware folder links. +// First, clear stale installed/ftp_path flags on games that have nothing left +// locally — this prevents orphan FTP-only ghosts from persisting when the PS5 +// is disconnected. Then delete the now-fully-orphaned rows. +function pruneOrphanScanGames() { + // Clear stale PS5 flags on games with no backups and no firmware links so + // they don't survive the DELETE below purely on a stale installed flag. + return { changes: 0, retainedForCatalogIdentity: true }; +} + +// ── Stats ───────────────────────────────────────────────────────────────────── +function getStats() { + return { + totalGames: prepare('SELECT COUNT(*) as c FROM titles').get()?.c ?? 0, + installed: prepare("SELECT COUNT(DISTINCT title_id) as c FROM console_installs WHERE install_state = 'observed'").get()?.c ?? 0, + backedUp: prepare("SELECT COUNT(DISTINCT title_id) as c FROM local_copies WHERE availability_state != 'absent'").get()?.c ?? 0, + totalBackups: prepare("SELECT COUNT(*) as c FROM local_copies WHERE availability_state != 'absent'").get()?.c ?? 0, + }; +} + +// ── DB Viewer ───────────────────────────────────────────────────────────────── +function runQuery(sql) { + const safeSql = assertReadOnlySql(sql); + const statement = db.prepare(safeSql); + const rows = []; + const maxRows = 2_000; + try { + while (statement.step()) { + if (rows.length >= maxRows) throw new Error(`De query levert meer dan ${maxRows} rijen op. Voeg een LIMIT of specifieker filter toe.`); + rows.push(statement.getAsObject()); + } + return rows; + } finally { + statement.free(); + } +} + +function runReadOnlySqlWithParams(sql, params = {}) { + const safeSql = assertReadOnlySql(sql); + const statement = prepare(safeSql); + const rows = statement.all(params); + const maxRows = 2_000; + if (rows.length > maxRows) throw new Error(`De query levert meer dan ${maxRows} rijen op. Voeg een LIMIT of specifieker filter toe.`); + return rows; +} + +function checkForeignKeys() { + return prepare('PRAGMA foreign_key_check').all(); +} + +function runMutatingSql(sql) { + return prepare(sql).run(); +} + +function runMutatingSqlWithParams(sql, params = {}) { + return prepare(sql).run(params); +} + +function runInTransaction(fn) { + db.run('BEGIN;'); + try { + const result = fn(); + db.run('COMMIT;'); + return result; + } catch (error) { + try { + db.run('ROLLBACK;'); + } catch (_) {} + throw error; + } +} + +// ── Export ──────────────────────────────────────────────────────────────────── +function exportSql() { + const tables = ['games', 'backups']; + let out = `-- Chimera Export ${new Date().toISOString()}\n\n`; + + for (const table of tables) { + const info = prepare(`PRAGMA table_info(${table})`).all(); + const cols = info.map(c => c.name); + const rows = prepare(`SELECT * FROM ${table}`).all(); + + out += `-- ${table}\n`; + for (const row of rows) { + const vals = cols.map(c => { + const v = row[c]; + return v === null || v === undefined ? 'NULL' : `'${String(v).replace(/'/g, "''")}'`; + }).join(', '); + out += `INSERT INTO ${table} (${cols.join(', ')}) VALUES (${vals});\n`; + } + out += '\n'; + } + return out; +} + +function exportCsv(table) { + // Guard against injection via the table name — only allow tables that exist. + const known = prepare(`SELECT name FROM sqlite_master WHERE type='table'`).all().map(r => r.name); + if (!known.includes(table)) throw new Error(`Unknown table: ${table}`); + const rows = prepare(`SELECT * FROM ${table}`).all(); + if (!rows.length) return ''; + const headers = Object.keys(rows[0]); + return [ + headers.join(','), + ...rows.map(r => headers.map(h => `"${(r[h] ?? '').toString().replace(/"/g, '""')}"`).join(',')), + ].join('\n'); +} + +// ── Backpork folders ────────────────────────────────────────────────────────── +function listBackporkFolders() { + return prepare(` + SELECT bf.*, COUNT(gf.game_id) as game_count + FROM backpork_folders bf + LEFT JOIN game_firmware gf ON gf.folder_name = bf.name + GROUP BY bf.id + ORDER BY bf.name ASC + `).all(); +} + +function addBackporkFolder(name, folderPath) { + return prepare(` + INSERT INTO backpork_folders (name, path) VALUES (@name, @path) + ON CONFLICT(path) DO UPDATE SET name = excluded.name + `).run({ name, path: folderPath }); +} + +function removeBackporkFolder(id) { + const folder = prepare('SELECT name FROM backpork_folders WHERE id = @id').get({ id }); + if (folder) prepare('DELETE FROM game_firmware WHERE folder_name = @name').run({ name: folder.name }); + return prepare('DELETE FROM backpork_folders WHERE id = @id').run({ id }); +} + +// Minimal upsert — only creates the row if missing; never overwrites FTP-scanned data +function upsertGameMinimal(game_id) { + return prepare(` + INSERT INTO games (game_id, title, last_scanned) + VALUES (@game_id, '', datetime('now')) + ON CONFLICT(game_id) DO UPDATE SET last_scanned = excluded.last_scanned + `).run({ game_id }); +} + +function markConsoleInstallAbsent(game_id, remote_location_key = '') { + return runMutatingSqlWithParams(`UPDATE console_installs SET install_state = 'absent', updated_at = datetime('now') + WHERE title_id IN (SELECT id FROM titles WHERE UPPER(title_id) = UPPER(@gameId)) + AND (@remoteLocationKey = '' OR remote_location_key = @remoteLocationKey)`, + { gameId: String(game_id || ''), remoteLocationKey: String(remote_location_key || '') }); +} +function semanticEvidence() { + const { createSemanticEvidenceService } = require('./services/semantic-evidence-service'); + return createSemanticEvidenceService({ runQuery, runMutatingSqlWithParams }); +} +function recordSemanticEvidence(input) { return semanticEvidence().record(input); } +function listSemanticEvidence(input) { return semanticEvidence().list(input); } +function getLegacySemanticDiagnostics() { return semanticEvidence().diagnostics(); } + +function setGameFirmwareLabel(game_id, folder_name) { + return prepare(` + INSERT OR IGNORE INTO game_firmware (game_id, folder_name) VALUES (@game_id, @folder_name) + `).run({ game_id, folder_name }); +} + +// Remove firmware label entries for games no longer found in a specific folder. +// keepGameIds is a Set of game_ids that were found during the current scan. +function pruneGameFirmwareForFolder(folder_name, keepGameIds) { + const rows = prepare('SELECT game_id FROM game_firmware WHERE folder_name = @folder_name').all({ folder_name }); + let removed = 0; + for (const r of rows) { + if (!keepGameIds.has(r.game_id)) { + prepare('DELETE FROM game_firmware WHERE game_id = @game_id AND folder_name = @folder_name') + .run({ game_id: r.game_id, folder_name }); + removed++; + } + } + if (removed > 0) save(); + return removed; +} + +function listFirmwareLabels() { + return prepare('SELECT DISTINCT folder_name FROM game_firmware ORDER BY folder_name ASC').all() + .map(r => r.folder_name); +} + +// Returns all games in a specific backpork firmware folder, with game data +function listGamesForFolder(folder_name) { + return prepare(` + SELECT g.*, + (SELECT COUNT(*) FROM backups b WHERE b.game_id = g.game_id AND b.source = 'local') as backup_count + FROM games g + JOIN game_firmware gf ON gf.game_id = g.game_id + WHERE gf.folder_name = @folder_name + ORDER BY COALESCE(g.prospero_name, g.title, g.game_id) ASC + `).all({ folder_name }); +} + +// Returns firmware folder entries for a specific game (for Manage tab backpork list). +// game_path is the game-specific subfolder within the backpork folder. +function getGameBackporkEntries(game_id) { + return prepare(` + SELECT gf.folder_name, + bf.path AS folder_path, + b.backup_path AS game_path + FROM game_firmware gf + JOIN backpork_folders bf ON bf.name = gf.folder_name + LEFT JOIN backups b + ON b.game_id = @game_id + AND b.source = 'backpork' + AND b.backup_path LIKE bf.path || '%' + WHERE gf.game_id = @game_id + ORDER BY gf.folder_name ASC + `).all({ game_id }); +} + +function setGamePorked(game_id, firmware_label) { + return prepare(` + UPDATE games SET porked_firmware = @firmware_label, porked_at = datetime('now') + WHERE game_id = @game_id + `).run({ game_id, firmware_label }); +} + +// ── Payload Sources ─────────────────────────────────────────────────────────── +function listPayloadSources() { + return prepare('SELECT * FROM payload_sources ORDER BY name ASC').all(); +} + +function addPayloadSource({ name, github_url, category = 'other', description = '', homepage = '', trust = 'custom', built_in = 0, auto_update = 0 }) { + return prepare(` + INSERT INTO payload_sources (name, github_url, category, description, homepage, trust, built_in, auto_update) + VALUES (@name, @github_url, @category, @description, @homepage, @trust, @built_in, @auto_update) + `).run({ name, github_url, category, description, homepage, trust, built_in, auto_update }); +} + +function upsertPayloadSource(source) { + return prepare(` + INSERT INTO payload_sources (name, github_url, category, description, homepage, trust, built_in, auto_update) + VALUES (@name, @github_url, @category, @description, @homepage, @trust, @built_in, @auto_update) + ON CONFLICT(github_url) DO UPDATE SET + name = CASE WHEN payload_sources.built_in = 1 THEN excluded.name ELSE payload_sources.name END, + category = CASE WHEN payload_sources.built_in = 1 THEN excluded.category ELSE payload_sources.category END, + description = CASE WHEN payload_sources.built_in = 1 THEN excluded.description ELSE payload_sources.description END, + homepage = CASE WHEN payload_sources.built_in = 1 THEN excluded.homepage ELSE payload_sources.homepage END, + trust = CASE WHEN payload_sources.built_in = 1 THEN excluded.trust ELSE payload_sources.trust END, + built_in = MAX(payload_sources.built_in, excluded.built_in) + `).run({ + name: source.name, github_url: source.github_url, category: source.category || 'other', + description: source.description || '', homepage: source.homepage || source.github_url, + trust: source.trust || 'custom', built_in: source.built_in ? 1 : 0, + auto_update: source.auto_update ? 1 : 0, + }); +} + +function removePayloadSource(id) { + return prepare('DELETE FROM payload_sources WHERE id = @id').run({ id }); +} + +function togglePayloadSource(id) { + return prepare('UPDATE payload_sources SET enabled = NOT enabled WHERE id = @id').run({ id }); +} + +function updatePayloadSourceChecked(id, tag, error = null) { + return prepare(` + UPDATE payload_sources SET last_checked = @now, latest_tag = COALESCE(@tag, latest_tag), last_error = @error WHERE id = @id + `).run({ id, now: Date.now(), tag, error: error ? String(error).slice(0, 500) : null }); +} + +function updatePayloadSourceError(id, error) { + return prepare(`UPDATE payload_sources SET last_error = @error WHERE id = @id`) + .run({ id, error: String(error || '').slice(0, 500) }); +} + +function upsertPayloadFile({ source_id, asset_name, asset_url, remote_hash, version, asset_size = null, published_at = null, download_count = null, content_type = null, release_url = null }) { + return prepare(` + INSERT INTO payload_files (source_id, asset_name, asset_url, remote_hash, version, asset_size, published_at, download_count, content_type, release_url) + VALUES (@source_id, @asset_name, @asset_url, @remote_hash, @version, @asset_size, @published_at, @download_count, @content_type, @release_url) + ON CONFLICT(source_id, asset_name) DO UPDATE SET + asset_url = excluded.asset_url, + remote_hash = excluded.remote_hash, + version = excluded.version, + asset_size = excluded.asset_size, + published_at = excluded.published_at, + download_count = excluded.download_count, + content_type = excluded.content_type, + release_url = excluded.release_url + `).run({ source_id, asset_name, asset_url: asset_url ?? null, remote_hash: remote_hash ?? null, version: version ?? null, asset_size, published_at, download_count, content_type, release_url }); +} + +function updatePayloadFileLocal({ source_id, asset_name, local_path, local_hash, local_size, version }) { + return prepare(` + UPDATE payload_files SET + local_path = @local_path, + local_hash = @local_hash, + local_size = @local_size, + local_version = @version, + downloaded_at = @now + WHERE source_id = @source_id AND asset_name = @asset_name + `).run({ source_id, asset_name, local_path: local_path ?? null, local_hash: local_hash ?? null, local_size: local_size ?? null, version: version ?? null, now: Date.now() }); +} + +function getPayloadFiles(source_id) { + return prepare('SELECT * FROM payload_files WHERE source_id = @source_id ORDER BY asset_name ASC').all({ source_id }); +} + +function getPayloadCatalog() { + const sources = listPayloadSources(); + const files = prepare(` + SELECT f.*, s.name AS source_name, s.category, s.trust, s.homepage, s.enabled + FROM payload_files f JOIN payload_sources s ON s.id = f.source_id + ORDER BY s.name COLLATE NOCASE, f.asset_name COLLATE NOCASE + `).all(); + return { + sources, + files, + stats: { + sources: sources.length, + enabledSources: sources.filter(source => source.enabled).length, + available: files.length, + downloaded: files.filter(file => file.local_path).length, + updates: files.filter(file => file.local_hash && ((file.remote_hash && file.local_hash !== file.remote_hash) || (file.local_version && file.version && file.local_version !== file.version))).length, + verified: files.filter(file => file.remote_hash).length, + }, + }; +} + +function deletePayloadFile(id) { + return prepare('DELETE FROM payload_files WHERE id = @id').run({ id }); +} + +// ── Game hashes ─────────────────────────────────────────────────────────────── +function addGameHash({ game_id, file_hash, file_size, backup_path, hash_type = 'game', firmware_label = null, verified = 1 }) { + return prepare(` + INSERT INTO game_hashes (game_id, file_hash, file_size, backup_path, hash_type, firmware_label, verified) + VALUES (@game_id, @file_hash, @file_size, @backup_path, @hash_type, @firmware_label, @verified) + ON CONFLICT(backup_path) DO UPDATE SET + file_hash = excluded.file_hash, + file_size = excluded.file_size, + hash_type = excluded.hash_type, + firmware_label = excluded.firmware_label, + verified = excluded.verified, + hashed_at = datetime('now') + `).run({ game_id, file_hash, file_size, backup_path, hash_type, firmware_label, verified }); +} + +// Return the firmware folder name for a given backup path (for backpork hashes). +function getBackporkFirmwareLabel(backup_path) { + const row = prepare(` + SELECT name FROM backpork_folders + WHERE @path LIKE path || '%' + ORDER BY LENGTH(path) DESC LIMIT 1 + `).get({ path: backup_path }); + return row?.name ?? null; +} + +function getGameHashes(game_id) { + return prepare('SELECT * FROM game_hashes WHERE game_id = @game_id ORDER BY hashed_at DESC').all({ game_id }); +} + +function exportAllHashes() { + // backup_path is intentionally excluded — it's a local filesystem path + // and carries no meaning for community hash sharing. + return prepare( + 'SELECT game_id, file_hash, file_size FROM game_hashes WHERE verified = 1 ORDER BY game_id' + ).all(); +} + +// All hashed rows (lightweight — includes type/label fields for summary). +function getAllGameHashes() { + return prepare('SELECT game_id, file_hash, hash_type, firmware_label FROM game_hashes WHERE verified = 1').all(); +} + +// Quick count of verified hashes. +function getHashCount() { + return prepare('SELECT COUNT(*) as c FROM game_hashes WHERE verified = 1').get()?.c ?? 0; +} + +function resetAllProsperoFetchedAt() { + prepare('UPDATE games SET prospero_fetched_at = NULL').run(); + save(); + return prepare('SELECT game_id FROM games').all().map(r => r.game_id); +} + +// ── Cheats ──────────────────────────────────────────────────────────────────── +function upsertCheatFile(filename, cusaId, version, title, dataJson) { + prepare(` + INSERT INTO cheat_files (filename, cusa_id, version, title, data, cached_at) + VALUES (@filename, @cusa_id, @version, @title, @data, datetime('now')) + ON CONFLICT(filename) DO UPDATE SET + title = excluded.title, + data = excluded.data, + cached_at = excluded.cached_at + `).run({ filename, cusa_id: cusaId, version, title, data: dataJson }); +} + +function getCheatFiles(cusaId) { + return prepare(`SELECT filename, cusa_id, version, title, data, cached_at + FROM cheat_files WHERE cusa_id = @cusa_id ORDER BY version` + ).all({ cusa_id: cusaId }); +} + +function searchCheats(query) { + const q = `%${query}%`; + return prepare(` + SELECT filename, cusa_id, version, title, + (SELECT COUNT(*) FROM cheat_files c2 WHERE c2.cusa_id = cf.cusa_id) as file_count + FROM cheat_files cf + WHERE cf.title LIKE @q OR cf.cusa_id LIKE @q + GROUP BY cf.cusa_id + ORDER BY cf.title ASC + LIMIT 200 + `).all({ q }); +} + +function listAllCheatsGrouped() { + return prepare(` + SELECT cusa_id, title, COUNT(*) as file_count + FROM cheat_files + GROUP BY cusa_id + ORDER BY title ASC + `).all({}); +} + +function listCachedFilenames() { + return prepare('SELECT filename FROM cheat_files').all({}).map(r => r.filename); +} + +function markMetadataFailure(game_id, error, retry_at) { + return prepare(` + UPDATE games SET + metadata_failure_count = COALESCE(metadata_failure_count, 0) + 1, + metadata_last_error = @error, + metadata_retry_at = @retry_at + WHERE game_id = @game_id + `).run({ game_id, error: String(error || '').slice(0, 500), retry_at }); +} + +function getGamesDueMetadata({ now = Date.now(), maxAgeMs = 7 * 24 * 60 * 60 * 1000 } = {}) { + return prepare(` + SELECT * FROM games + WHERE (metadata_retry_at IS NULL OR metadata_retry_at <= @now) + AND ( + prospero_fetched_at IS NULL OR + prospero_fetched_at <= @stale OR + cover_fetched_at IS NULL OR + cover_fetched_at <= @stale OR + COALESCE(prospero_name, '') = '' OR + COALESCE(prospero_icon_url, '') = '' + ) + ORDER BY COALESCE(metadata_retry_at, 0) ASC, COALESCE(prospero_fetched_at, 0) ASC + `).all({ now, stale: now - maxAgeMs }); +} + +function getGameCatalogHealth() { + const row = prepare(` + SELECT + COUNT(*) AS total, + SUM(CASE WHEN COALESCE(prospero_name, '') != '' AND COALESCE(prospero_cover_url, '') != '' THEN 1 ELSE 0 END) AS complete, + SUM(CASE WHEN COALESCE(prospero_name, '') = '' THEN 1 ELSE 0 END) AS missing_metadata, + SUM(CASE WHEN COALESCE(prospero_cover_url, '') = '' THEN 1 ELSE 0 END) AS missing_artwork, + SUM(CASE WHEN COALESCE(metadata_failure_count, 0) > 0 THEN 1 ELSE 0 END) AS retrying, + MAX(prospero_fetched_at) AS last_enriched + FROM games + `).get(); + return Object.fromEntries(Object.entries(row || {}).map(([key, value]) => [key, value ?? 0])); +} + +function listCachedCheatEntries() { + return prepare('SELECT filename, cached_at FROM cheat_files').all({}); +} + +function deleteCheatFilesNotIn(filenames) { + const keep = new Set((filenames || []).map(String)); + let deleted = 0; + for (const row of prepare('SELECT id, filename FROM cheat_files').all({})) { + if (keep.has(row.filename)) continue; + prepare('DELETE FROM cheat_files WHERE id = @id').run({ id: row.id }); + deleted += 1; + } + return deleted; +} + +function getAllCheatFilesWithData() { + return prepare('SELECT filename, data FROM cheat_files ORDER BY filename').all({}); +} + +function getCheatStats() { + const row = prepare('SELECT COUNT(*) as files, COUNT(DISTINCT cusa_id) as games FROM cheat_files').get({}); + return row || { files: 0, games: 0 }; +} + +function getUnmatchedCheats() { + return prepare(`SELECT filename, cusa_id, version, title, cached_at + FROM cheat_files + WHERE cusa_id IS NULL OR cusa_id = '' + ORDER BY title ASC`).all({}); +} + +function assignCheatCusaId(filename, cusaId) { + prepare(`UPDATE cheat_files SET cusa_id = @cusa_id WHERE filename = @filename`) + .run({ filename, cusa_id: cusaId.trim().toUpperCase() }); + save(); +} + +function getGamesMissingIconUrl() { + return prepare(`SELECT game_id FROM games WHERE game_id != '' AND (prospero_icon_url IS NULL OR prospero_icon_url = '')`).all().map(r => r.game_id); +} + +function clearHashes() { + db.run('DELETE FROM game_hashes'); + save(); +} + +function clearAll() { + db.run('DELETE FROM cheat_files'); + db.run('DELETE FROM payload_files'); + db.run('DELETE FROM payload_sources'); + db.run('DELETE FROM game_hashes'); + db.run('DELETE FROM game_firmware'); + db.run('DELETE FROM backpork_folders'); + db.run('DELETE FROM backups'); + db.run('DELETE FROM games'); + save(); +} + +/** + * Selectively clear specific categories of data. + * @param {Object} opts + * @param {boolean} opts.games - Clear games table + * @param {boolean} opts.backups - Clear backups table + * @param {boolean} opts.hashes - Clear game_hashes table + * @param {boolean} opts.backporks - Clear backpork_folders + game_firmware tables + * @param {boolean} opts.payloads - Clear payload_sources + payload_files tables + * @param {boolean} opts.cheats - Clear cheat_files table + */ +function clearSelective(opts = {}) { + if (opts.cheats) db.run('DELETE FROM cheat_files'); + if (opts.payloads) { + db.run('DELETE FROM payload_files'); + db.run('DELETE FROM payload_sources'); + } + if (opts.hashes) db.run('DELETE FROM game_hashes'); + if (opts.backporks) { + db.run('DELETE FROM game_firmware'); + db.run('DELETE FROM backpork_folders'); + } + if (opts.backups) db.run('DELETE FROM backups'); + if (opts.games) { + // Clearing games also clears backup records so games don't reappear + // on the next rescan (upsertGameMinimal would otherwise recreate them + // from the orphaned backup rows that pointed to the same folders). + db.run('DELETE FROM backups'); + db.run('DELETE FROM game_hashes'); + db.run('DELETE FROM game_firmware'); + db.run('DELETE FROM games'); + } + save(); +} + +/** + * Delete specific rows by id from an allowed table. + * @param {string} table - One of 'games', 'backups', 'game_hashes' + * @param {number[]} ids - Array of row ids to delete + * @returns {number} Number of rows deleted + */ +function deleteRows(table, ids) { + const allowed = ['games', 'backups', 'game_hashes']; + if (!allowed.includes(table)) throw new Error(`Cannot delete from table: ${table}`); + if (!ids || !ids.length) return 0; + // sql.js doesn't support array params, so use IN with placeholders + const placeholders = ids.map(() => '?').join(','); + const stmt = db.prepare(`DELETE FROM ${table} WHERE id IN (${placeholders})`); + stmt.bind(ids); + stmt.step(); + stmt.free(); + const deleted = db.getRowsModified(); + // If we deleted games, also clean up related backups/hashes/firmware for those game_ids + if (table === 'games' && ids.length) { + // Get game_ids we just deleted — they're gone, so clean up by orphan check + db.run(`DELETE FROM backups WHERE game_id NOT IN (SELECT game_id FROM games WHERE game_id != '')`); + db.run(`DELETE FROM game_hashes WHERE game_id NOT IN (SELECT game_id FROM games WHERE game_id != '')`); + db.run(`DELETE FROM game_firmware WHERE game_id NOT IN (SELECT game_id FROM games WHERE game_id != '')`); + } + save(); + return deleted; +} + +function close() { + if (db) { saveNow(); db.close(); } +} + +// ── Auto-Backpork Generator helpers ─────────────────────────────────────────── + +/** Games that have at least one local folder backup (usable as backpork source). + * Returns exactly one row per game using the most recently added backup path. */ +function listGamesWithLocalFolder() { + return prepare(` + SELECT g.game_id, g.title, g.prospero_name, g.prospero_icon_url, + b.backup_path + FROM games g + JOIN backups b ON b.rowid = ( + SELECT MAX(b2.rowid) FROM backups b2 + WHERE b2.game_id = g.game_id + AND b2.source = 'local' + AND b2.backup_type = 'folder' + ) + ORDER BY COALESCE(g.prospero_name, g.title, g.game_id) + `).all(); +} + +/** + * Set of game_ids that already have a backpork for the given firmware label. + * Pass null/'' to return all game_ids that have ANY backpork. + */ +function gameIdsWithBackporkForFw(firmwareLabel) { + if (firmwareLabel) { + return new Set( + prepare('SELECT DISTINCT game_id FROM game_firmware WHERE folder_name = @fw') + .all({ fw: firmwareLabel }).map(r => r.game_id) + ); + } + return new Set( + prepare("SELECT DISTINCT game_id FROM backups WHERE source = 'backpork'") + .all().map(r => r.game_id) + ); +} + +function startAutomationRun({ task_id, trigger = 'manual', started_at = Date.now() }) { + prepare(` + INSERT INTO automation_runs (task_id, trigger, status, started_at) + VALUES (@task_id, @trigger, 'running', @started_at) + `).run({ task_id, trigger, started_at }); + return Number(prepare('SELECT last_insert_rowid() AS id').get()?.id || 0); +} + +function finishAutomationRun(id, { status, summary = null, error = null, finished_at = Date.now() }) { + const row = prepare('SELECT started_at FROM automation_runs WHERE id = @id').get({ id }); + if (!row) return false; + prepare(` + UPDATE automation_runs + SET status = @status, + finished_at = @finished_at, + duration_ms = @duration_ms, + summary = @summary, + error = @error + WHERE id = @id + `).run({ + id, + status, + finished_at, + duration_ms: Math.max(0, finished_at - Number(row.started_at || finished_at)), + summary, + error, + }); + return true; +} + +function listAutomationRuns(limit = 100) { + const safeLimit = Math.min(500, Math.max(1, Number(limit) || 100)); + return prepare(` + SELECT * FROM automation_runs + ORDER BY started_at DESC, id DESC + LIMIT ${safeLimit} + `).all(); +} + +function exportDatabaseBuffer() { + if (!db) throw new Error('Database is niet geopend.'); + return Buffer.from(db.export()); +} + +function restoreDatabaseBuffer(buffer) { + if (!SQLRuntime) throw new Error('Database-runtime is niet geïnitialiseerd.'); + const input = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer || []); + if (!input.length || input.length > 512 * 1024 * 1024) throw new Error('Ongeldige snapshotdatabase.'); + const candidate = new SQLRuntime.Database(input); + try { + const integrity = candidate.exec('PRAGMA integrity_check;')?.[0]?.values?.[0]?.[0]; + if (integrity !== 'ok') throw new Error(`Database-integriteitscontrole: ${integrity || 'mislukt'}`); + const tables = candidate.exec("SELECT name FROM sqlite_master WHERE type='table'")?.[0]?.values?.flat() || []; + if (!tables.includes('games') || !tables.includes('automation_runs')) throw new Error('Snapshot mist verplichte Chimera-tabellen.'); + candidate.run('PRAGMA foreign_keys = ON;'); + const restored = candidate.export(); + atomicDatabaseWrite(restored); + const previous = db; + db = candidate; + try { previous?.close(); } catch (_) {} + return { ok: true, bytes: restored.length }; + } catch (error) { + try { candidate.close(); } catch (_) {} + throw error; + } +} + +function compatibilityRepository() { + if (!db) throw new Error('Database is not initialized.'); + return require('./services/compatibility-provider-service').createSqliteCompatibilityRepository({ db }); +} + +function saveCompatibilitySnapshot(snapshot) { return compatibilityRepository().save(snapshot); } +function getCompatibilitySnapshot(providerId) { return compatibilityRepository().latest(providerId); } +function listCompatibilitySnapshots() { return compatibilityRepository().list(); } + +module.exports = { + configure, + initialize, listLibraryTitles, listGames, upsertGame, updateGame, getGame, + updateProsperoData, getProsperoAge, resetAllProsperoFetchedAt, + markMetadataFailure, getGamesDueMetadata, getGameCatalogHealth, + listBackups, upsertBackup, deleteBackup, purgeArchiveBackups, clearBackupsForGame, + deleteStaleBackupsInFolder, syncBackedUpFlags, pruneOrphanScanGames, getStats, + runQuery, runReadOnlySqlWithParams, checkForeignKeys, exportSql, exportCsv, + saveCompatibilitySnapshot, getCompatibilitySnapshot, listCompatibilitySnapshots, + listBackporkFolders, addBackporkFolder, removeBackporkFolder, + upsertGameMinimal, markConsoleInstallAbsent, setGameFirmwareLabel, pruneGameFirmwareForFolder, listFirmwareLabels, + listGamesForFolder, getGameBackporkEntries, setGamePorked, + addGameHash, getGameHashes, exportAllHashes, getAllGameHashes, getHashCount, getBackporkFirmwareLabel, + listPayloadSources, addPayloadSource, upsertPayloadSource, removePayloadSource, togglePayloadSource, + updatePayloadSourceChecked, updatePayloadSourceError, upsertPayloadFile, updatePayloadFileLocal, + getPayloadFiles, getPayloadCatalog, deletePayloadFile, + upsertCheatFile, getCheatFiles, searchCheats, listAllCheatsGrouped, + listCachedFilenames, listCachedCheatEntries, deleteCheatFilesNotIn, + getAllCheatFilesWithData, getCheatStats, getUnmatchedCheats, assignCheatCusaId, + getGamesMissingIconUrl, + listGamesWithLocalFolder, gameIdsWithBackporkForFw, + startAutomationRun, finishAutomationRun, listAutomationRuns, + exportDatabaseBuffer, restoreDatabaseBuffer, + runMutatingSql, runMutatingSqlWithParams, runInTransaction, + beginBulkUpdate, endBulkUpdate, bulkUpdate, + clearHashes, clearAll, clearSelective, deleteRows, close, + setTestMigrationFailureVersion, recordSemanticEvidence, listSemanticEvidence, getLegacySemanticDiagnostics, +}; diff --git a/src/database/migrations/0001-create-core-schema.js b/src/database/migrations/0001-create-core-schema.js new file mode 100644 index 0000000..abcd9bc --- /dev/null +++ b/src/database/migrations/0001-create-core-schema.js @@ -0,0 +1,133 @@ +'use strict'; + +module.exports = { + version: 1, + description: 'Create baseline Chimera schema and initial indexes', + up(db) { + db.run(`CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + checksum TEXT NOT NULL, + description TEXT NOT NULL, + status TEXT NOT NULL, + applied_at INTEGER NOT NULL, + error TEXT DEFAULT NULL, + snapshot_path TEXT DEFAULT NULL, + recovery_instructions TEXT DEFAULT NULL, + row_count_delta_json TEXT DEFAULT NULL + )`); + + db.run(`CREATE TABLE IF NOT EXISTS games ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL DEFAULT '', + game_id TEXT UNIQUE NOT NULL, + content_id TEXT DEFAULT '', + size INTEGER DEFAULT 0, + version TEXT DEFAULT '', + installed INTEGER DEFAULT 0, + backed_up INTEGER DEFAULT 0, + ftp_path TEXT DEFAULT '', + last_scanned TEXT DEFAULT NULL, + created_at TEXT DEFAULT (datetime('now')) + )`); + + db.run(`CREATE TABLE IF NOT EXISTS backups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + game_id TEXT NOT NULL, + backup_path TEXT NOT NULL UNIQUE, + size INTEGER DEFAULT 0, + backup_type TEXT DEFAULT 'dump', + source TEXT DEFAULT 'local', + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (game_id) REFERENCES games(game_id) ON DELETE CASCADE + )`); + + db.run(`CREATE TABLE IF NOT EXISTS backpork_folders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + path TEXT NOT NULL UNIQUE, + created_at TEXT DEFAULT (datetime('now')) + )`); + + db.run(`CREATE TABLE IF NOT EXISTS game_firmware ( + game_id TEXT NOT NULL, + folder_name TEXT NOT NULL, + PRIMARY KEY (game_id, folder_name) + )`); + + db.run(`CREATE TABLE IF NOT EXISTS game_hashes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + game_id TEXT NOT NULL, + file_hash TEXT NOT NULL, + file_size INTEGER NOT NULL, + backup_path TEXT NOT NULL UNIQUE, + verified INTEGER DEFAULT 1, + hashed_at TEXT DEFAULT (datetime('now')) + )`); + + db.run(`CREATE TABLE IF NOT EXISTS payload_sources ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + github_url TEXT NOT NULL UNIQUE, + enabled INTEGER DEFAULT 1, + last_checked INTEGER DEFAULT NULL, + latest_tag TEXT DEFAULT NULL, + category TEXT DEFAULT 'other', + description TEXT DEFAULT '', + homepage TEXT DEFAULT '', + trust TEXT DEFAULT 'custom', + built_in INTEGER DEFAULT 0, + auto_update INTEGER DEFAULT 0, + last_error TEXT DEFAULT NULL + )`); + + db.run(`CREATE TABLE IF NOT EXISTS payload_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_id INTEGER NOT NULL, + asset_name TEXT NOT NULL, + asset_url TEXT DEFAULT NULL, + remote_hash TEXT DEFAULT NULL, + local_path TEXT DEFAULT NULL, + local_hash TEXT DEFAULT NULL, + local_size INTEGER DEFAULT NULL, + version TEXT DEFAULT NULL, + local_version TEXT DEFAULT NULL, + downloaded_at INTEGER DEFAULT NULL, + asset_size INTEGER DEFAULT NULL, + published_at TEXT DEFAULT NULL, + download_count INTEGER DEFAULT NULL, + content_type TEXT DEFAULT NULL, + release_url TEXT DEFAULT NULL, + UNIQUE(source_id, asset_name), + FOREIGN KEY (source_id) REFERENCES payload_sources(id) ON DELETE CASCADE + )`); + + db.run(`CREATE TABLE IF NOT EXISTS cheat_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + filename TEXT UNIQUE NOT NULL, + cusa_id TEXT NOT NULL, + version TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL DEFAULT '', + data TEXT NOT NULL DEFAULT '{}', + cached_at TEXT DEFAULT (datetime('now')) + )`); + + db.run(`CREATE TABLE IF NOT EXISTS automation_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL, + trigger TEXT NOT NULL DEFAULT 'manual', + status TEXT NOT NULL DEFAULT 'running', + started_at INTEGER NOT NULL, + finished_at INTEGER DEFAULT NULL, + duration_ms INTEGER DEFAULT NULL, + summary TEXT DEFAULT NULL, + error TEXT DEFAULT NULL + )`); + + db.run(`CREATE INDEX IF NOT EXISTS idx_cheat_cusa ON cheat_files(cusa_id)`); + db.run(`CREATE INDEX IF NOT EXISTS idx_backups_game_id ON backups(game_id)`); + db.run(`CREATE INDEX IF NOT EXISTS idx_fw_game_id ON game_firmware(game_id)`); + db.run(`CREATE INDEX IF NOT EXISTS idx_hashes_game_id ON game_hashes(game_id)`); + db.run(`CREATE INDEX IF NOT EXISTS idx_bpfolders_path ON backpork_folders(path)`); + db.run(`CREATE INDEX IF NOT EXISTS idx_automation_task ON automation_runs(task_id, started_at DESC)`); + }, +}; diff --git a/src/database/migrations/0002-migrate-legacy-columns.js b/src/database/migrations/0002-migrate-legacy-columns.js new file mode 100644 index 0000000..64aa6db --- /dev/null +++ b/src/database/migrations/0002-migrate-legacy-columns.js @@ -0,0 +1,144 @@ +'use strict'; + +function columnExists(database, table, column) { + const rows = database.exec(`PRAGMA table_info(${table})`)?.[0]?.values || []; + return rows.some(([_, name]) => name === column); +} + +function ensureColumn(database, table, column, definition) { + if (!columnExists(database, table, column)) { + database.run(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`); + } +} + +module.exports = { + version: 2, + description: 'Apply legacy column migrations and one-time enrichment backfills', + up(database) { + ensureColumn(database, 'backups', 'source', "TEXT DEFAULT 'local'"); + ensureColumn(database, 'backups', 'backup_type', "TEXT DEFAULT 'folder'"); + ensureColumn(database, 'backups', 'size', 'INTEGER DEFAULT 0'); + if (!columnExists(database, 'backups', 'source')) { + // Failsafe for environments where ALTER TABLE to add the source column is + // unavailable (e.g. legacy SQL.js edge cases). + if (!database.exec("SELECT 1 FROM sqlite_master WHERE type='table' AND name='backups'").length) { + throw new Error('Backups table missing during legacy migration'); + } + } + database.exec(`CREATE INDEX IF NOT EXISTS idx_backups_source ON backups(source)`); + + ensureColumn(database, 'game_hashes', 'hash_type', "TEXT DEFAULT 'game'"); + ensureColumn(database, 'game_hashes', 'firmware_label', 'TEXT DEFAULT NULL'); + + ensureColumn(database, 'payload_sources', 'category', "TEXT DEFAULT 'other'"); + ensureColumn(database, 'payload_sources', 'description', "TEXT DEFAULT ''"); + ensureColumn(database, 'payload_sources', 'homepage', "TEXT DEFAULT ''"); + ensureColumn(database, 'payload_sources', 'trust', "TEXT DEFAULT 'custom'"); + ensureColumn(database, 'payload_sources', 'built_in', 'INTEGER DEFAULT 0'); + ensureColumn(database, 'payload_sources', 'auto_update', 'INTEGER DEFAULT 0'); + ensureColumn(database, 'payload_sources', 'last_error', 'TEXT DEFAULT NULL'); + + ensureColumn(database, 'payload_files', 'asset_size', 'INTEGER DEFAULT NULL'); + ensureColumn(database, 'payload_files', 'published_at', 'TEXT DEFAULT NULL'); + ensureColumn(database, 'payload_files', 'download_count', 'INTEGER DEFAULT NULL'); + ensureColumn(database, 'payload_files', 'content_type', 'TEXT DEFAULT NULL'); + ensureColumn(database, 'payload_files', 'release_url', 'TEXT DEFAULT NULL'); + ensureColumn(database, 'payload_files', 'local_path', 'TEXT DEFAULT NULL'); + ensureColumn(database, 'payload_files', 'local_version', 'TEXT DEFAULT NULL'); + + const prosperoColumns = [ + ['porked_firmware', 'TEXT DEFAULT NULL'], + ['porked_at', 'TEXT DEFAULT NULL'], + ['prospero_name', 'TEXT DEFAULT NULL'], + ['prospero_publisher', 'TEXT DEFAULT NULL'], + ['prospero_publisher_id', 'TEXT DEFAULT NULL'], + ['prospero_icon_url', 'TEXT DEFAULT NULL'], + ['prospero_banner_url', 'TEXT DEFAULT NULL'], + ['prospero_cover_url', 'TEXT DEFAULT NULL'], + ['cover_source', 'TEXT DEFAULT NULL'], + ['cover_source_url', 'TEXT DEFAULT NULL'], + ['cover_fetched_at', 'INTEGER DEFAULT NULL'], + ['prospero_region', 'TEXT DEFAULT NULL'], + ['prospero_last_updated', 'TEXT DEFAULT NULL'], + ['prospero_patch_count', 'INTEGER DEFAULT NULL'], + ['prospero_fetched_at', 'INTEGER DEFAULT NULL'], + ['prospero_patches', 'TEXT DEFAULT NULL'], + ['prospero_dlc', 'TEXT DEFAULT NULL'], + ['prospero_other_regions', 'TEXT DEFAULT NULL'], + ['prospero_description', 'TEXT DEFAULT NULL'], + ['prospero_content_id', 'TEXT DEFAULT NULL'], + ['prospero_version', 'TEXT DEFAULT NULL'], + ['prospero_size', 'TEXT DEFAULT NULL'], + ['metadata_failure_count', 'INTEGER DEFAULT 0'], + ['metadata_last_error', 'TEXT DEFAULT NULL'], + ['metadata_retry_at', 'INTEGER DEFAULT NULL'], + ['metadata_source', 'TEXT DEFAULT NULL'], + ['metadata_confidence', 'TEXT DEFAULT NULL'], + ['metadata_override', 'TEXT DEFAULT NULL'], + ]; + for (const [column, definition] of prosperoColumns) { + ensureColumn(database, 'games', column, definition); + } + + database.run(`UPDATE payload_files SET local_version = version WHERE local_path IS NOT NULL AND local_version IS NULL`); + + database.run(` + UPDATE game_hashes + SET hash_type = 'backpork', + firmware_label = ( + SELECT bf.name FROM backpork_folders bf + WHERE game_hashes.backup_path LIKE bf.path || '%' + ORDER BY LENGTH(bf.path) DESC + LIMIT 1 + ) + WHERE hash_type = 'game' + AND EXISTS ( + SELECT 1 FROM backpork_folders bf + WHERE game_hashes.backup_path LIKE bf.path || '%' + ) + `); + + // Reset fetch timestamp for titles with missing icon URLs. + database.run(` + UPDATE games SET prospero_fetched_at = NULL + WHERE (prospero_icon_url IS NULL OR prospero_icon_url = '') + AND prospero_fetched_at IS NOT NULL + `); + + // Re-mark older encyclopedia and SteamGridDB references for re-fetch. + database.run(` + UPDATE games + SET cover_fetched_at = NULL + WHERE (prospero_cover_url IS NULL OR prospero_cover_url = '') + AND cover_fetched_at IS NOT NULL + AND COALESCE(cover_source, '') NOT IN ('wikipedia-no-match', 'catalog-no-match') + `); + database.run(`UPDATE games SET cover_fetched_at = NULL WHERE cover_source = 'wikipedia'`); + database.run(` + UPDATE games + SET prospero_cover_url = NULL, + cover_source = 'steamgriddb-retired', + cover_source_url = NULL, + cover_fetched_at = NULL + WHERE cover_source = 'steamgriddb' + OR prospero_cover_url LIKE '%steamgriddb.com%' + `); + database.run(` + UPDATE games + SET prospero_cover_url = NULL, + cover_source = 'wikipedia-retired', + cover_source_url = NULL, + cover_fetched_at = NULL + WHERE cover_source IN ('wikipedia', 'wikipedia-fallback', 'wikipedia-portrait-v2') + `); + + database.run(`DELETE FROM backups WHERE backup_type NOT IN ('folder','pkg','iso')`); + database.run(` + UPDATE backups SET source = 'backpork' + WHERE EXISTS ( + SELECT 1 FROM backpork_folders bf + WHERE backups.backup_path LIKE bf.path || '%' + ) + `); + }, +}; diff --git a/src/database/migrations/0003-create-v3-core.js b/src/database/migrations/0003-create-v3-core.js new file mode 100644 index 0000000..60d2917 --- /dev/null +++ b/src/database/migrations/0003-create-v3-core.js @@ -0,0 +1,319 @@ +'use strict'; + +module.exports = { + version: 3, + description: 'Create v3 core entities for titles, copies, installs, devices, volumes and scan runs', + up(db) { + db.run(` + CREATE TABLE IF NOT EXISTS titles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform TEXT NOT NULL DEFAULT 'unknown', + title_id TEXT DEFAULT NULL, + content_id TEXT DEFAULT NULL, + np_communication_id TEXT DEFAULT NULL, + name TEXT NOT NULL DEFAULT '', + sort_name TEXT NOT NULL DEFAULT '', + region TEXT DEFAULT NULL, + metadata_json TEXT DEFAULT NULL, + metadata_source TEXT DEFAULT NULL, + metadata_seen_at INTEGER DEFAULT NULL, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), + UNIQUE (platform, title_id, content_id) + ) + `); + + db.run(` + CREATE TABLE IF NOT EXISTS title_aliases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title_id INTEGER NOT NULL, + alias_type TEXT NOT NULL DEFAULT 'alternate_id', + alias_value TEXT NOT NULL, + confidence TEXT DEFAULT NULL, + provenance TEXT DEFAULT NULL, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (title_id) REFERENCES titles(id) ON DELETE CASCADE + ) + `); + db.run('CREATE UNIQUE INDEX IF NOT EXISTS idx_title_aliases_unique ON title_aliases(title_id, alias_type, alias_value)'); + + db.run(` + CREATE TABLE IF NOT EXISTS storage_devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scope TEXT NOT NULL DEFAULT 'server', + console_profile_id TEXT DEFAULT NULL, + stable_fingerprint TEXT NOT NULL, + display_name TEXT DEFAULT '', + device_type TEXT NOT NULL DEFAULT 'unknown', + serial_hash TEXT DEFAULT NULL, + last_seen_at INTEGER DEFAULT NULL, + availability_state TEXT NOT NULL DEFAULT 'unknown', + metadata_json TEXT DEFAULT NULL, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), + UNIQUE (scope, stable_fingerprint) + ) + `); + db.run('CREATE INDEX IF NOT EXISTS idx_storage_devices_fingerprint ON storage_devices(stable_fingerprint)'); + + db.run(` + CREATE TABLE IF NOT EXISTS storage_volumes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_id INTEGER NOT NULL, + stable_volume_key TEXT NOT NULL, + mount_path TEXT DEFAULT NULL, + capacity_bytes INTEGER DEFAULT NULL, + free_bytes INTEGER DEFAULT NULL, + space_seen_at INTEGER DEFAULT NULL, + can_play INTEGER DEFAULT 0, + can_install INTEGER DEFAULT 0, + can_copy INTEGER DEFAULT 0, + can_mount INTEGER DEFAULT 0, + read_only INTEGER DEFAULT 0, + filesystem TEXT DEFAULT NULL, + availability_state TEXT NOT NULL DEFAULT 'unknown', + metadata_json TEXT DEFAULT NULL, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (device_id) REFERENCES storage_devices(id) ON DELETE CASCADE, + UNIQUE (device_id, stable_volume_key) + ) + `); + db.run('CREATE INDEX IF NOT EXISTS idx_storage_volumes_device ON storage_volumes(device_id)'); + + db.run(` + CREATE TABLE IF NOT EXISTS scan_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL DEFAULT 'local', + console_profile_id TEXT DEFAULT NULL, + started_at INTEGER NOT NULL, + completed_at INTEGER DEFAULT NULL, + outcome TEXT DEFAULT 'running', + requested_scope_json TEXT DEFAULT NULL, + observed_count INTEGER DEFAULT 0, + error_summary TEXT DEFAULT NULL, + provider_version TEXT DEFAULT NULL + ) + `); + db.run('CREATE INDEX IF NOT EXISTS idx_scan_runs_started ON scan_runs(started_at DESC)'); + + db.run(` + CREATE TABLE IF NOT EXISTS local_copies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title_id INTEGER NOT NULL, + volume_id INTEGER NOT NULL, + relative_path TEXT NOT NULL, + copy_type TEXT NOT NULL DEFAULT 'folder', + base_version TEXT DEFAULT NULL, + version_source TEXT DEFAULT NULL, + size_bytes INTEGER DEFAULT NULL, + inventory_hash TEXT DEFAULT NULL, + hash_algorithm TEXT DEFAULT NULL, + hash_state TEXT DEFAULT NULL, + integrity_state TEXT DEFAULT NULL, + last_verified_at INTEGER DEFAULT NULL, + scan_run_id INTEGER DEFAULT NULL, + last_seen_at INTEGER DEFAULT NULL, + availability_state TEXT DEFAULT 'unknown', + manifest_id TEXT DEFAULT NULL, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (title_id) REFERENCES titles(id) ON DELETE CASCADE, + FOREIGN KEY (volume_id) REFERENCES storage_volumes(id) ON DELETE CASCADE, + FOREIGN KEY (scan_run_id) REFERENCES scan_runs(id) ON DELETE SET NULL, + UNIQUE (title_id, volume_id, copy_type, relative_path) + ) + `); + db.run('CREATE INDEX IF NOT EXISTS idx_local_copies_title_last_seen ON local_copies(title_id, last_seen_at DESC)'); + db.run('CREATE INDEX IF NOT EXISTS idx_local_copies_volume_last_seen ON local_copies(volume_id, last_seen_at DESC)'); + + db.run(` + CREATE TABLE IF NOT EXISTS version_evidence ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + subject_type TEXT NOT NULL, + subject_id INTEGER NOT NULL, + semantic_version TEXT DEFAULT NULL, + raw_version TEXT DEFAULT NULL, + version_source TEXT DEFAULT NULL, + observed_at INTEGER DEFAULT NULL, + confidence TEXT DEFAULT 'low', + compatible_context TEXT DEFAULT NULL, + evidence_json TEXT DEFAULT NULL + ) + `); + db.run('CREATE INDEX IF NOT EXISTS idx_version_evidence_subject ON version_evidence(subject_type, subject_id)'); + + db.run(` + CREATE TABLE IF NOT EXISTS console_installs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title_id INTEGER NOT NULL, + console_profile_id TEXT NOT NULL, + volume_id INTEGER NOT NULL, + remote_location_key TEXT NOT NULL, + installed_version TEXT DEFAULT NULL, + install_state TEXT NOT NULL DEFAULT 'unknown', + mount_state TEXT DEFAULT NULL, + format TEXT DEFAULT NULL, + first_seen_at INTEGER DEFAULT NULL, + last_seen_at INTEGER DEFAULT NULL, + last_confirmed_scan_run_id INTEGER DEFAULT NULL, + version_evidence_id INTEGER DEFAULT NULL, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (title_id) REFERENCES titles(id) ON DELETE CASCADE, + FOREIGN KEY (volume_id) REFERENCES storage_volumes(id) ON DELETE CASCADE, + FOREIGN KEY (last_confirmed_scan_run_id) REFERENCES scan_runs(id) ON DELETE SET NULL, + FOREIGN KEY (version_evidence_id) REFERENCES version_evidence(id) ON DELETE SET NULL, + UNIQUE (title_id, console_profile_id, volume_id, remote_location_key) + ) + `); + db.run('CREATE INDEX IF NOT EXISTS idx_console_installs_title_profile ON console_installs(title_id, console_profile_id)'); + db.run('CREATE INDEX IF NOT EXISTS idx_console_installs_volume_last_seen ON console_installs(volume_id, last_seen_at DESC)'); + db.run('CREATE INDEX IF NOT EXISTS idx_console_installs_state ON console_installs(install_state)'); + + db.run(` + CREATE TABLE IF NOT EXISTS scan_root_coverage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scan_run_id INTEGER NOT NULL, + root_key TEXT NOT NULL, + volume_id INTEGER DEFAULT NULL, + coverage_state TEXT NOT NULL DEFAULT 'unknown', + observed_count INTEGER DEFAULT 0, + error_code TEXT DEFAULT NULL, + updated_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (scan_run_id) REFERENCES scan_runs(id) ON DELETE CASCADE, + FOREIGN KEY (volume_id) REFERENCES storage_volumes(id) ON DELETE SET NULL, + UNIQUE (scan_run_id, root_key, volume_id) + ) + `); + db.run('CREATE INDEX IF NOT EXISTS idx_scan_root_coverage_run_state ON scan_root_coverage(scan_run_id, coverage_state)'); + + db.run(` + CREATE VIEW IF NOT EXISTS v_title_availability AS + SELECT + t.id AS title_id, + t.platform, + t.title_id AS external_title_id, + t.name, + COALESCE(lc.copy_count, 0) AS local_copy_count, + COALESCE(ci.install_count, 0) AS console_install_count, + COALESCE(ci.confirmed_count, 0) AS confirmed_install_count, + COALESCE(ci.stale_count, 0) AS stale_install_count, + COALESCE(ci.absent_count, 0) AS absent_install_count, + COALESCE(ci.unavailable_count, 0) AS device_unavailable_install_count, + COALESCE(ld.missing_device_count, 0) AS missing_device_count, + CASE + WHEN COALESCE(ci.confirmed_count, 0) > 0 THEN 'installed' + WHEN COALESCE(ci.stale_count, 0) > 0 THEN 'stale' + WHEN COALESCE(ci.absent_count, 0) > 0 THEN 'absent' + WHEN COALESCE(ci.unavailable_count, 0) > 0 THEN 'device_unavailable' + WHEN COALESCE(ci.install_count, 0) > 0 THEN 'unknown_install_state' + WHEN COALESCE(lc.copy_count, 0) > 0 THEN 'copy_only' + ELSE 'unknown' + END AS derived_state + FROM titles t + LEFT JOIN ( + SELECT + local_copies.title_id, + COUNT(*) AS copy_count + FROM local_copies + GROUP BY local_copies.title_id + ) lc ON lc.title_id = t.id + LEFT JOIN ( + SELECT + console_installs.title_id, + COUNT(*) AS install_count, + SUM(CASE WHEN install_state = 'observed' THEN 1 ELSE 0 END) AS confirmed_count, + SUM(CASE WHEN install_state = 'stale' THEN 1 ELSE 0 END) AS stale_count, + SUM(CASE WHEN install_state = 'absent' THEN 1 ELSE 0 END) AS absent_count, + SUM(CASE WHEN install_state = 'device_unavailable' THEN 1 ELSE 0 END) AS unavailable_count + FROM console_installs + GROUP BY console_installs.title_id + ) ci ON ci.title_id = t.id + LEFT JOIN ( + SELECT + console_installs.title_id, + SUM(CASE WHEN storage_devices.availability_state = 'unavailable' THEN 1 ELSE 0 END) AS missing_device_count + FROM console_installs + INNER JOIN storage_volumes ON storage_volumes.id = console_installs.volume_id + INNER JOIN storage_devices ON storage_devices.id = storage_volumes.device_id + GROUP BY console_installs.title_id + ) ld ON ld.title_id = t.id; + `); + + db.run(` + CREATE VIEW IF NOT EXISTS v_title_install_summary AS + SELECT + titles.id AS title_id, + titles.title_id AS external_title_id, + console_installs.id AS console_install_id, + console_installs.console_profile_id, + console_installs.volume_id, + storage_volumes.stable_volume_key, + storage_devices.id AS device_id, + storage_devices.stable_fingerprint AS device_fingerprint, + console_installs.remote_location_key, + console_installs.installed_version, + console_installs.install_state, + console_installs.mount_state, + console_installs.format, + console_installs.first_seen_at, + console_installs.last_seen_at, + CASE WHEN console_installs.install_state = 'observed' THEN 1 ELSE 0 END AS is_confirmed_install + FROM console_installs + JOIN titles ON titles.id = console_installs.title_id + JOIN storage_volumes ON storage_volumes.id = console_installs.volume_id + JOIN storage_devices ON storage_devices.id = storage_volumes.device_id; + `); + + db.run(` + CREATE VIEW IF NOT EXISTS v_title_update_status AS + SELECT + titles.id AS title_id, + titles.title_id AS external_title_id, + ( + SELECT console_installs.installed_version + FROM console_installs + WHERE console_installs.title_id = titles.id + AND console_installs.install_state = 'observed' + ORDER BY COALESCE(console_installs.last_seen_at, 0) DESC, console_installs.id DESC + LIMIT 1 + ) AS confirmed_installed_version, + ( + SELECT console_installs.last_seen_at + FROM console_installs + WHERE console_installs.title_id = titles.id + AND console_installs.install_state = 'observed' + ORDER BY COALESCE(console_installs.last_seen_at, 0) DESC, console_installs.id DESC + LIMIT 1 + ) AS confirmed_installed_seen_at, + ( + SELECT COUNT(*) + FROM console_installs ci + WHERE ci.title_id = titles.id + AND ci.install_state = 'observed' + ) AS confirmed_install_count + FROM titles; + `); + + db.run(` + CREATE VIEW IF NOT EXISTS v_protection_health AS + SELECT + titles.id AS title_id, + titles.title_id AS external_title_id, + COALESCE(cp.local_copy_count, 0) AS local_copy_count, + COALESCE(cp.distinct_copy_device_count, 0) AS distinct_copy_device_count, + CASE WHEN COALESCE(cp.distinct_copy_device_count, 0) >= 2 THEN 1 ELSE 0 END AS protection_healthy + FROM titles + LEFT JOIN ( + SELECT + local_copies.title_id, + COUNT(*) AS local_copy_count, + COUNT(DISTINCT storage_volumes.device_id) AS distinct_copy_device_count + FROM local_copies + JOIN storage_volumes ON storage_volumes.id = local_copies.volume_id + GROUP BY local_copies.title_id + ) cp ON cp.title_id = titles.id; + `); + }, +}; diff --git a/src/database/migrations/0004-migrate-legacy-games-to-v3.js b/src/database/migrations/0004-migrate-legacy-games-to-v3.js new file mode 100644 index 0000000..0b98f7e --- /dev/null +++ b/src/database/migrations/0004-migrate-legacy-games-to-v3.js @@ -0,0 +1,334 @@ +'use strict'; + +function rowsFrom(result) { + if (!result || !result.length) return []; + const set = result[0]; + const cols = set.columns || []; + return set.values.map((valueRow) => { + const row = {}; + cols.forEach((name, i) => { + row[name] = valueRow[i]; + }); + return row; + }); +} + +function scalar(result) { + return result?.[0]?.values?.[0]?.[0] ?? null; +} + +function quote(value) { + if (value === null || value === undefined) return 'NULL'; + return `'${String(value).replace(/'/g, "''")}'`; +} + +function platformFromGameId(gameId) { + const id = String(gameId || '').toUpperCase(); + if (id.startsWith('CUSA')) return 'ps4'; + if (id.startsWith('PPSA')) return 'ps5'; + return 'unknown'; +} + +function copyTypeFromPath(filePath) { + const lower = String(filePath || '').toLowerCase(); + if (lower.endsWith('.pkg') || lower.endsWith('.zip') || lower.endsWith('.7z') || lower.endsWith('.rar')) return 'archive'; + if (lower.endsWith('.img') || lower.endsWith('.bin')) return 'image'; + return 'folder'; +} + +function normaliseLocationPath(filePath) { + return String(filePath || '').replace(/\\/g, '/').replace(/\/+/g, '/'); +} + +function deriveVolumeKey(filePath) { + const normalized = normaliseLocationPath(filePath); + const trimmed = normalized.replace(/^\/+/, ''); + const parts = trimmed.split('/').filter(Boolean); + if (!parts.length) return 'legacy:unknown'; + return `legacy:${parts.slice(0, 2).join('/')}`; +} + +function deriveDeviceFingerprint(filePath) { + const normalized = normaliseLocationPath(filePath); + const trimmed = normalized.replace(/^\/+/, ''); + const parts = trimmed.split('/').filter(Boolean); + if (!parts.length) return 'legacy:unknown-device'; + return `legacy:${parts[0]}`; +} + +function deriveRelativePath(filePath) { + const normalized = normaliseLocationPath(filePath); + const trimmed = normalized.replace(/^\/+/, ''); + const parts = trimmed.split('/').filter(Boolean); + return parts.length > 1 ? parts.slice(1).join('/') : trimmed || 'legacy-root'; +} + +module.exports = { + version: 4, + description: 'Migrate legacy games/backups/version evidence into v3 title/location tables', + up(database) { + database.run(` + CREATE TABLE IF NOT EXISTS migration_reports ( + migration_name TEXT PRIMARY KEY, + created_at TEXT DEFAULT (datetime('now')), + report_json TEXT NOT NULL + ) + `); + + const gameRows = rowsFrom(database.exec(` + SELECT id, title, game_id, content_id, version, size, installed, backed_up, ftp_path + FROM games + ORDER BY game_id, id + `)); + const backupRows = rowsFrom(database.exec(` + SELECT b.id, b.game_id, b.backup_path, g.title, g.version, g.content_id + FROM backups b + LEFT JOIN games g ON g.game_id = b.game_id + ORDER BY b.id + `)); + + if (!gameRows.length && !backupRows.length) { + database.run(` + INSERT OR REPLACE INTO migration_reports (migration_name, report_json) + VALUES ('legacy-to-v3', '${JSON.stringify({ + migration: '0004-migrate-legacy-games-to-v3', + legacyGames: 0, + legacyBackups: 0, + titlesCreated: 0, + localCopiesCreated: 0, + consoleInstallsCreated: 0, + legacyUnverifiedInstalls: [], + ambiguousVersions: [], + })}') + `); + return; + } + + const titleCache = new Map(); + const deviceCache = new Map(); + const volumeCache = new Map(); + const versionBuckets = new Map(); + let titlesCreated = 0; + let localCopiesCreated = 0; + let consoleInstallsCreated = 0; + const legacyUnverifiedInstalls = []; + + const getTitleId = (row) => { + const gameId = row.game_id || ''; + if (titleCache.has(gameId)) return titleCache.get(gameId); + + const platform = platformFromGameId(gameId); + const existing = scalar(database.exec(` + SELECT id FROM titles + WHERE platform = ${quote(platform)} + AND title_id = ${quote(gameId)} + `)); + if (existing) { + titleCache.set(gameId, existing); + return existing; + } + + const contentId = row.content_id || ''; + database.run(` + INSERT INTO titles (platform, title_id, content_id, name, sort_name, region, metadata_source) + VALUES ( + ${quote(platform)}, + ${quote(gameId)}, + ${quote(contentId)}, + ${quote(row.title || row.title_id || gameId || 'Unknown Title')}, + ${quote(row.title || row.title_id || gameId || 'Unknown Title')}, + 'unknown', + 'legacy' + ) + `); + const titleId = scalar(database.exec('SELECT last_insert_rowid() as id')); + titleCache.set(gameId, titleId); + titlesCreated += 1; + return titleId; + }; + + const getDeviceId = (pathHint) => { + const key = `server|${deriveDeviceFingerprint(pathHint)}`; + if (deviceCache.has(key)) return deviceCache.get(key); + + const fingerprint = deriveDeviceFingerprint(pathHint); + const existing = scalar(database.exec(` + SELECT id FROM storage_devices + WHERE scope = 'server' + AND stable_fingerprint = ${quote(fingerprint)} + `)); + if (existing) { + deviceCache.set(key, existing); + return existing; + } + + database.run(` + INSERT INTO storage_devices (scope, stable_fingerprint, display_name, device_type, availability_state) + VALUES ('server', ${quote(fingerprint)}, ${quote(`Legacy ${fingerprint}`)}, 'server', 'available') + `); + const deviceId = scalar(database.exec('SELECT last_insert_rowid() as id')); + deviceCache.set(key, deviceId); + return deviceId; + }; + + const getVolumeId = (pathHint, deviceId) => { + const volumeKey = deriveVolumeKey(pathHint); + const cacheKey = `${deviceId}|${volumeKey}`; + if (volumeCache.has(cacheKey)) return volumeCache.get(cacheKey); + + const existing = scalar(database.exec(` + SELECT id FROM storage_volumes + WHERE device_id = ${Number(deviceId)} + AND stable_volume_key = ${quote(volumeKey)} + `)); + if (existing) { + volumeCache.set(cacheKey, existing); + return existing; + } + + database.run(` + INSERT INTO storage_volumes ( + device_id, + stable_volume_key, + mount_path, + can_copy, + can_play, + can_install, + can_mount + ) VALUES ( + ${Number(deviceId)}, + ${quote(volumeKey)}, + ${quote(`/${volumeKey}`)}, + 1, + 1, + 1, + 1 + ) + `); + const volumeId = scalar(database.exec('SELECT last_insert_rowid() as id')); + volumeCache.set(cacheKey, volumeId); + return volumeId; + }; + + const addVersionEvidence = (gameId, version) => { + if (!version) return; + const values = versionBuckets.get(gameId) || new Set(); + values.add(String(version)); + versionBuckets.set(gameId, values); + }; + + for (const game of gameRows) { + addVersionEvidence(game.game_id, game.version); + const titleId = getTitleId(game); + if (Number(game.installed) === 1 && game.ftp_path) { + const volumeId = getVolumeId(game.ftp_path, getDeviceId(game.ftp_path)); + const relativePath = deriveRelativePath(game.ftp_path); + const remoteKey = `legacy:${normaliseLocationPath(game.ftp_path)}`; + const installState = 'legacy-unverified'; + const alreadyInstalled = scalar(database.exec(` + SELECT COUNT(*) AS c FROM console_installs + WHERE title_id = ${Number(titleId)} + AND console_profile_id = 'legacy-console' + AND volume_id = ${Number(volumeId)} + AND remote_location_key = ${quote(remoteKey)} + `)); + if (alreadyInstalled) { + continue; + } + database.run(` + INSERT OR IGNORE INTO console_installs ( + title_id, console_profile_id, volume_id, remote_location_key, install_state, + installed_version, first_seen_at, last_seen_at, created_at, updated_at + ) VALUES ( + ${Number(titleId)}, + 'legacy-console', + ${Number(volumeId)}, + ${quote(remoteKey)}, + ${quote(installState)}, + NULL, + ${Date.now()}, + ${Date.now()}, + datetime('now'), + datetime('now') + ) + `); + consoleInstallsCreated += 1; + legacyUnverifiedInstalls.push({ + title_id: game.game_id || '', + title: game.title || '', + path: remoteKey, + state: installState, + }); + } + } + + for (const backup of backupRows) { + const gameLike = { + game_id: backup.game_id, + content_id: backup.content_id || '', + title: backup.title || backup.game_id || 'Unknown Title', + version: backup.version, + }; + const titleId = getTitleId(gameLike); + const deviceId = getDeviceId(backup.backup_path); + const volumeId = getVolumeId(backup.backup_path, deviceId); + const relativePath = deriveRelativePath(backup.backup_path); + const copyType = copyTypeFromPath(backup.backup_path); + + const before = scalar(database.exec(` + SELECT COUNT(*) AS c FROM local_copies + WHERE title_id = ${Number(titleId)} + AND volume_id = ${Number(volumeId)} + AND relative_path = ${quote(relativePath)} + AND copy_type = ${quote(copyType)} + `)); + if (before) { + continue; + } + + database.run(` + INSERT INTO local_copies ( + title_id, volume_id, relative_path, copy_type, base_version, version_source, + availability_state, last_seen_at, created_at, updated_at + ) VALUES ( + ${Number(titleId)}, + ${Number(volumeId)}, + ${quote(relativePath)}, + ${quote(copyType)}, + ${quote(backup.version || '')}, + 'legacy-game.version', + 'available', + ${Date.now()}, + datetime('now'), + datetime('now') + ) + `); + localCopiesCreated += 1; + } + + const ambiguousVersions = []; + for (const [gameId, versions] of versionBuckets.entries()) { + const values = Array.from(versions.values()).filter((value) => value !== ''); + if (values.length > 1) { + ambiguousVersions.push({ game_id: gameId, versions: values.sort() }); + } + } + + database.run(` + INSERT OR REPLACE INTO migration_reports (migration_name, report_json) + VALUES ( + 'legacy-to-v3', + ${quote(JSON.stringify({ + migration: '0004-migrate-legacy-games-to-v3', + legacyGames: gameRows.length, + legacyBackups: backupRows.length, + titlesCreated, + localCopiesCreated, + consoleInstallsCreated, + legacyUnverifiedInstalls, + ambiguousVersions, + }))} + ) + `); + }, +}; diff --git a/src/database/migrations/0005-create-semantic-evidence-overrides.js b/src/database/migrations/0005-create-semantic-evidence-overrides.js new file mode 100644 index 0000000..57d08fd --- /dev/null +++ b/src/database/migrations/0005-create-semantic-evidence-overrides.js @@ -0,0 +1,10 @@ +'use strict'; +module.exports = { version: 5, description: 'Create expiring manual semantic evidence overrides', up(db) { + db.run(`CREATE TABLE IF NOT EXISTS semantic_evidence_overrides ( + id INTEGER PRIMARY KEY AUTOINCREMENT, title_id INTEGER NOT NULL, + evidence_kind TEXT NOT NULL, evidence_value TEXT NOT NULL, reason TEXT NOT NULL, + expires_at INTEGER NOT NULL, created_at INTEGER NOT NULL, + created_by TEXT NOT NULL DEFAULT 'local-user', revoked_at INTEGER DEFAULT NULL, + FOREIGN KEY (title_id) REFERENCES titles(id) ON DELETE CASCADE)`); + db.run('CREATE INDEX IF NOT EXISTS idx_semantic_overrides_title_kind_expiry ON semantic_evidence_overrides(title_id, evidence_kind, expires_at)'); +} }; diff --git a/src/database/migrations/0006-add-storage-evidence.js b/src/database/migrations/0006-add-storage-evidence.js new file mode 100644 index 0000000..d4dafee --- /dev/null +++ b/src/database/migrations/0006-add-storage-evidence.js @@ -0,0 +1,24 @@ +'use strict'; + +function columns(db, table) { + return new Set((db.exec(`PRAGMA table_info(${table})`)?.[0]?.values || []).map(row => String(row[1]))); +} + +function addColumn(db, table, name, definition) { + if (!columns(db, table).has(name)) db.run(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`); +} + +module.exports = { + version: 6, + description: 'Add explicit storage identity, capacity and capability provenance', + up(db) { + addColumn(db, 'storage_devices', 'adapter_id', 'TEXT DEFAULT NULL'); + addColumn(db, 'storage_devices', 'identity_source', 'TEXT DEFAULT NULL'); + addColumn(db, 'storage_devices', 'identity_seen_at', 'INTEGER DEFAULT NULL'); + addColumn(db, 'storage_volumes', 'capacity_source', 'TEXT DEFAULT NULL'); + addColumn(db, 'storage_volumes', 'capabilities_json', 'TEXT DEFAULT NULL'); + addColumn(db, 'storage_volumes', 'capabilities_source', 'TEXT DEFAULT NULL'); + addColumn(db, 'storage_volumes', 'capabilities_seen_at', 'INTEGER DEFAULT NULL'); + db.run('CREATE INDEX IF NOT EXISTS idx_storage_devices_adapter ON storage_devices(scope, adapter_id, availability_state)'); + }, +}; diff --git a/src/database/migrations/0007-add-storage-friendly-label.js b/src/database/migrations/0007-add-storage-friendly-label.js new file mode 100644 index 0000000..14e3e3e --- /dev/null +++ b/src/database/migrations/0007-add-storage-friendly-label.js @@ -0,0 +1,10 @@ +'use strict'; + +module.exports = { + version: 7, + description: 'Add persistent user-friendly labels for storage devices', + up(db) { + const names = new Set((db.exec('PRAGMA table_info(storage_devices)')?.[0]?.values || []).map(row => String(row[1]))); + if (!names.has('friendly_label')) db.run('ALTER TABLE storage_devices ADD COLUMN friendly_label TEXT DEFAULT NULL'); + }, +}; diff --git a/src/database/migrations/0008-create-guided-game-moves.js b/src/database/migrations/0008-create-guided-game-moves.js new file mode 100644 index 0000000..8cdbdce --- /dev/null +++ b/src/database/migrations/0008-create-guided-game-moves.js @@ -0,0 +1,26 @@ +'use strict'; + +module.exports = { + version: 8, + description: 'Create durable guided game move plans with linked before and after evidence', + up(db) { + db.run(` + CREATE TABLE IF NOT EXISTS guided_game_moves ( + id TEXT PRIMARY KEY, + title_id TEXT NOT NULL, + source_volume_id INTEGER NOT NULL, + destination_volume_id INTEGER NOT NULL, + required_bytes INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'planned', + before_snapshot_json TEXT NOT NULL, + after_snapshot_json TEXT DEFAULT NULL, + diff_json TEXT DEFAULT NULL, + steps_json TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + cancelled_at INTEGER DEFAULT NULL + ) + `); + db.run('CREATE INDEX IF NOT EXISTS idx_guided_game_moves_title_status ON guided_game_moves(title_id, status, updated_at DESC)'); + }, +}; diff --git a/src/database/migrations/0009-create-package-sets.js b/src/database/migrations/0009-create-package-sets.js new file mode 100644 index 0000000..7d9c341 --- /dev/null +++ b/src/database/migrations/0009-create-package-sets.js @@ -0,0 +1,81 @@ +'use strict'; + +module.exports = { + version: 9, + description: 'Create package sets, components, variants and dependency edges', + up(db) { + db.run(` + CREATE TABLE IF NOT EXISTS package_sets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title_id INTEGER NOT NULL, + set_key TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + variant_key TEXT NOT NULL DEFAULT 'default', + parent_set_id INTEGER DEFAULT NULL, + platform TEXT NOT NULL DEFAULT 'unknown', + region TEXT DEFAULT NULL, + source TEXT NOT NULL DEFAULT 'manual', + observed_at INTEGER NOT NULL, + confidence TEXT NOT NULL DEFAULT 'low' CHECK (confidence IN ('low', 'medium', 'high', 'verified')), + manifest_schema_version INTEGER DEFAULT NULL, + manifest_sha256 TEXT DEFAULT NULL, + manifest_json TEXT DEFAULT NULL, + provenance_json TEXT NOT NULL DEFAULT '[]', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (title_id) REFERENCES titles(id) ON DELETE CASCADE, + FOREIGN KEY (parent_set_id) REFERENCES package_sets(id) ON DELETE SET NULL, + UNIQUE (title_id, set_key) + ) + `); + db.run('CREATE INDEX IF NOT EXISTS idx_package_sets_title_variant ON package_sets(title_id, variant_key)'); + db.run('CREATE INDEX IF NOT EXISTS idx_package_sets_manifest_hash ON package_sets(manifest_sha256)'); + + db.run(` + CREATE TABLE IF NOT EXISTS package_components ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + package_set_id INTEGER NOT NULL, + component_key TEXT NOT NULL, + component_type TEXT NOT NULL CHECK (component_type IN ('base', 'update', 'dlc', 'unlocker', 'auxiliary', 'patch_bundle', 'apr_ampr', 'image', 'dump', 'dump_folder', 'metadata')), + variant_key TEXT DEFAULT NULL, + variant_of_component_id INTEGER DEFAULT NULL, + artifact_id TEXT DEFAULT NULL, + original_relative_path TEXT NOT NULL, + normalized_relative_path TEXT NOT NULL, + size_bytes INTEGER NOT NULL CHECK (size_bytes >= 0), + sha256 TEXT NOT NULL CHECK (length(sha256) = 64), + version TEXT DEFAULT NULL, + content_id TEXT DEFAULT NULL, + region TEXT DEFAULT NULL, + required INTEGER NOT NULL DEFAULT 0 CHECK (required IN (0, 1)), + order_index INTEGER DEFAULT NULL CHECK (order_index IS NULL OR order_index > 0), + adapter_hint TEXT DEFAULT NULL, + provenance_json TEXT NOT NULL DEFAULT '[]', + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (package_set_id) REFERENCES package_sets(id) ON DELETE CASCADE, + FOREIGN KEY (variant_of_component_id) REFERENCES package_components(id) ON DELETE SET NULL, + UNIQUE (package_set_id, component_key) + ) + `); + db.run('CREATE INDEX IF NOT EXISTS idx_package_components_set_order ON package_components(package_set_id, order_index, id)'); + db.run('CREATE INDEX IF NOT EXISTS idx_package_components_hash ON package_components(sha256)'); + db.run('CREATE INDEX IF NOT EXISTS idx_package_components_content_version ON package_components(content_id, version)'); + + db.run(` + CREATE TABLE IF NOT EXISTS package_component_dependencies ( + package_set_id INTEGER NOT NULL, + component_id INTEGER NOT NULL, + depends_on_component_id INTEGER NOT NULL, + dependency_kind TEXT NOT NULL DEFAULT 'requires' CHECK (dependency_kind IN ('requires', 'after', 'optional')), + created_at TEXT DEFAULT (datetime('now')), + PRIMARY KEY (component_id, depends_on_component_id, dependency_kind), + FOREIGN KEY (package_set_id) REFERENCES package_sets(id) ON DELETE CASCADE, + FOREIGN KEY (component_id) REFERENCES package_components(id) ON DELETE CASCADE, + FOREIGN KEY (depends_on_component_id) REFERENCES package_components(id) ON DELETE CASCADE, + CHECK (component_id != depends_on_component_id) + ) + `); + db.run('CREATE INDEX IF NOT EXISTS idx_package_dependencies_set ON package_component_dependencies(package_set_id, component_id)'); + }, +}; diff --git a/src/database/migrations/0010-create-install-recipes.js b/src/database/migrations/0010-create-install-recipes.js new file mode 100644 index 0000000..8c6fd1f --- /dev/null +++ b/src/database/migrations/0010-create-install-recipes.js @@ -0,0 +1,45 @@ +'use strict'; + +module.exports = { + version: 10, + description: 'Create immutable versioned install recipes and structured steps', + up(db) { + db.run(` + CREATE TABLE IF NOT EXISTS install_recipes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + package_set_id INTEGER NOT NULL, + recipe_key TEXT NOT NULL, + version INTEGER NOT NULL CHECK (version > 0), + schema_version INTEGER NOT NULL DEFAULT 1 CHECK (schema_version = 1), + label TEXT NOT NULL DEFAULT '', + notes TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL DEFAULT 'draft' CHECK (state IN ('draft', 'frozen', 'retired')), + recipe_sha256 TEXT NOT NULL CHECK (length(recipe_sha256) = 64), + recipe_json TEXT NOT NULL, + validation_json TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL, + FOREIGN KEY (package_set_id) REFERENCES package_sets(id) ON DELETE CASCADE, + UNIQUE (package_set_id, recipe_key, version) + ) + `); + db.run('CREATE INDEX IF NOT EXISTS idx_install_recipes_set_key_version ON install_recipes(package_set_id, recipe_key, version DESC)'); + db.run(` + CREATE TABLE IF NOT EXISTS install_recipe_steps ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + recipe_id INTEGER NOT NULL, + step_key TEXT NOT NULL, + component_key TEXT NOT NULL, + action TEXT NOT NULL, + required INTEGER NOT NULL CHECK (required IN (0, 1)), + order_index INTEGER NOT NULL CHECK (order_index > 0), + depends_on_json TEXT NOT NULL DEFAULT '[]', + destination_caps_json TEXT NOT NULL DEFAULT '[]', + FOREIGN KEY (recipe_id) REFERENCES install_recipes(id) ON DELETE CASCADE, + UNIQUE (recipe_id, step_key), + UNIQUE (recipe_id, component_key), + UNIQUE (recipe_id, order_index) + ) + `); + db.run('CREATE INDEX IF NOT EXISTS idx_install_recipe_steps_recipe_order ON install_recipe_steps(recipe_id, order_index)'); + }, +}; diff --git a/src/database/migrations/0011-create-compatibility-findings.js b/src/database/migrations/0011-create-compatibility-findings.js new file mode 100644 index 0000000..1d4f8f6 --- /dev/null +++ b/src/database/migrations/0011-create-compatibility-findings.js @@ -0,0 +1,40 @@ +'use strict'; + +module.exports = { + version: 11, + description: 'Create bounded compatibility provider snapshots and findings', + up(db) { + db.run(` + CREATE TABLE IF NOT EXISTS compatibility_provider_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider_id TEXT NOT NULL UNIQUE, + revision TEXT NOT NULL, + source_url TEXT NOT NULL, + evidence_sha256 TEXT NOT NULL CHECK (length(evidence_sha256) = 64), + fetched_at INTEGER NOT NULL CHECK (fetched_at > 0), + schema_version INTEGER NOT NULL DEFAULT 1 CHECK (schema_version = 1), + document_json TEXT NOT NULL + ) + `); + db.run('CREATE INDEX IF NOT EXISTS idx_compatibility_snapshots_fetched ON compatibility_provider_snapshots(fetched_at DESC)'); + db.run(` + CREATE TABLE IF NOT EXISTS compatibility_findings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider_snapshot_id INTEGER NOT NULL, + finding_key TEXT NOT NULL, + title_id TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('compatible', 'incompatible', 'workaround', 'unknown')), + severity TEXT NOT NULL CHECK (severity IN ('info', 'warning', 'critical')), + confidence TEXT NOT NULL CHECK (confidence IN ('low', 'medium', 'high')), + explanation TEXT NOT NULL, + conflict_key TEXT NOT NULL, + applicability_json TEXT NOT NULL, + finding_json TEXT NOT NULL, + FOREIGN KEY (provider_snapshot_id) REFERENCES compatibility_provider_snapshots(id) ON DELETE CASCADE, + UNIQUE (provider_snapshot_id, finding_key) + ) + `); + db.run('CREATE INDEX IF NOT EXISTS idx_compatibility_findings_title ON compatibility_findings(title_id)'); + db.run('CREATE INDEX IF NOT EXISTS idx_compatibility_findings_conflict ON compatibility_findings(conflict_key)'); + }, +}; diff --git a/src/database/migrations/0012-create-trophy-psn-snapshots.js b/src/database/migrations/0012-create-trophy-psn-snapshots.js new file mode 100644 index 0000000..ade20c7 --- /dev/null +++ b/src/database/migrations/0012-create-trophy-psn-snapshots.js @@ -0,0 +1,37 @@ +'use strict'; + +module.exports = { + version: 12, + description: 'Create immutable read-only PSN trophy proof snapshots', + up(db) { + db.run(` + CREATE TABLE IF NOT EXISTS trophy_psn_snapshots ( + id TEXT PRIMARY KEY, + canonical_sha256 TEXT NOT NULL UNIQUE CHECK (length(canonical_sha256) = 64), + schema_version INTEGER NOT NULL CHECK (schema_version = 1), + provider_id TEXT NOT NULL, + provider_version TEXT NOT NULL, + account_id TEXT NOT NULL, + online_id TEXT NOT NULL, + region TEXT NOT NULL, + language TEXT NOT NULL, + fetched_at INTEGER NOT NULL CHECK (fetched_at > 0), + imported_at INTEGER NOT NULL CHECK (imported_at > 0), + title_count INTEGER NOT NULL CHECK (title_count >= 0), + trophy_count INTEGER NOT NULL CHECK (trophy_count >= 0), + snapshot_json TEXT NOT NULL, + provenance_json TEXT NOT NULL, + warnings_json TEXT NOT NULL + ) + `); + db.run('CREATE INDEX IF NOT EXISTS idx_trophy_psn_account_fetched ON trophy_psn_snapshots(account_id, fetched_at DESC)'); + db.run(`CREATE TRIGGER IF NOT EXISTS trophy_psn_snapshots_no_update + BEFORE UPDATE ON trophy_psn_snapshots BEGIN + SELECT RAISE(ABORT, 'trophy PSN snapshots are immutable'); + END`); + db.run(`CREATE TRIGGER IF NOT EXISTS trophy_psn_snapshots_no_delete + BEFORE DELETE ON trophy_psn_snapshots BEGIN + SELECT RAISE(ABORT, 'trophy PSN snapshots are immutable'); + END`); + }, +}; \ No newline at end of file diff --git a/src/database/migrations/0013-create-trophy-local-snapshots.js b/src/database/migrations/0013-create-trophy-local-snapshots.js new file mode 100644 index 0000000..1a21f9d --- /dev/null +++ b/src/database/migrations/0013-create-trophy-local-snapshots.js @@ -0,0 +1,38 @@ +'use strict'; + +module.exports = { + version: 13, + description: 'Create immutable read-only local trophy inventory snapshots', + up(db) { + db.run(` + CREATE TABLE IF NOT EXISTS trophy_local_snapshots ( + id TEXT PRIMARY KEY, + canonical_sha256 TEXT NOT NULL UNIQUE CHECK (length(canonical_sha256) = 64), + raw_sha256 TEXT NOT NULL CHECK (length(raw_sha256) = 64), + schema_version INTEGER NOT NULL CHECK (schema_version = 1), + provider_id TEXT NOT NULL, + provider_version TEXT NOT NULL, + firmware TEXT NOT NULL, + profile_id TEXT NOT NULL, + local_user_id TEXT NOT NULL, + account_id TEXT, + coverage_status TEXT NOT NULL CHECK (coverage_status IN ('complete', 'partial', 'inaccessible')), + observed_at INTEGER NOT NULL CHECK (observed_at > 0), + captured_at INTEGER NOT NULL CHECK (captured_at > 0), + title_count INTEGER NOT NULL CHECK (title_count >= 0), + trophy_count INTEGER NOT NULL CHECK (trophy_count >= 0), + snapshot_json TEXT NOT NULL, + provenance_json TEXT NOT NULL + ) + `); + db.run('CREATE INDEX IF NOT EXISTS idx_trophy_local_profile_observed ON trophy_local_snapshots(profile_id, observed_at DESC)'); + db.run(`CREATE TRIGGER IF NOT EXISTS trophy_local_snapshots_no_update + BEFORE UPDATE ON trophy_local_snapshots BEGIN + SELECT RAISE(ABORT, 'local trophy snapshots are immutable'); + END`); + db.run(`CREATE TRIGGER IF NOT EXISTS trophy_local_snapshots_no_delete + BEFORE DELETE ON trophy_local_snapshots BEGIN + SELECT RAISE(ABORT, 'local trophy snapshots are immutable'); + END`); + }, +}; \ No newline at end of file diff --git a/src/database/migrations/0014-create-trophy-account-mappings.js b/src/database/migrations/0014-create-trophy-account-mappings.js new file mode 100644 index 0000000..03a05e7 --- /dev/null +++ b/src/database/migrations/0014-create-trophy-account-mappings.js @@ -0,0 +1,24 @@ +'use strict'; + +module.exports = { + version: 14, + description: 'Create explicit revocable trophy account mappings', + up(db) { + db.run(` + CREATE TABLE IF NOT EXISTS trophy_account_mappings ( + id TEXT PRIMARY KEY, + psn_account_id TEXT NOT NULL, + local_profile_id TEXT NOT NULL, + created_at INTEGER NOT NULL CHECK (created_at > 0), + revoked_at INTEGER, + revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0), + mapping_hash TEXT NOT NULL CHECK (length(mapping_hash) = 64), + provenance_json TEXT NOT NULL, + revocation_reason TEXT + ) + `); + db.run('CREATE UNIQUE INDEX IF NOT EXISTS idx_trophy_mapping_active_psn ON trophy_account_mappings(psn_account_id) WHERE revoked_at IS NULL'); + db.run('CREATE UNIQUE INDEX IF NOT EXISTS idx_trophy_mapping_active_local ON trophy_account_mappings(local_profile_id) WHERE revoked_at IS NULL'); + db.run('CREATE INDEX IF NOT EXISTS idx_trophy_mapping_history ON trophy_account_mappings(psn_account_id, local_profile_id, created_at DESC)'); + }, +}; \ No newline at end of file diff --git a/src/database/migrations/0015-create-trophy-reconstruction-plans.js b/src/database/migrations/0015-create-trophy-reconstruction-plans.js new file mode 100644 index 0000000..032e5bd --- /dev/null +++ b/src/database/migrations/0015-create-trophy-reconstruction-plans.js @@ -0,0 +1,39 @@ +'use strict'; + +module.exports = { + version: 15, + description: 'Create immutable proof-constrained trophy reconstruction plans', + up(db) { + db.run(` + CREATE TABLE IF NOT EXISTS trophy_reconstruction_plans ( + id TEXT PRIMARY KEY, + plan_sha256 TEXT NOT NULL UNIQUE CHECK (length(plan_sha256) = 64), + idempotency_key TEXT NOT NULL UNIQUE CHECK (length(idempotency_key) = 64), + psn_snapshot_id TEXT NOT NULL, + psn_snapshot_hash TEXT NOT NULL CHECK (length(psn_snapshot_hash) = 64), + local_snapshot_id TEXT NOT NULL, + local_snapshot_hash TEXT NOT NULL CHECK (length(local_snapshot_hash) = 64), + mapping_id TEXT NOT NULL, + mapping_revision INTEGER NOT NULL CHECK (mapping_revision > 0), + mapping_state_hash TEXT NOT NULL CHECK (length(mapping_state_hash) = 64), + context_hash TEXT NOT NULL CHECK (length(context_hash) = 64), + adapter_id TEXT NOT NULL, + adapter_version TEXT NOT NULL, + timestamp_policy TEXT NOT NULL CHECK (timestamp_policy IN ('preserve_exact', 'preserve_date_only', 'current_time_only', 'unsupported')), + created_at INTEGER NOT NULL CHECK (created_at > 0), + expires_at INTEGER NOT NULL CHECK (expires_at > created_at), + item_count INTEGER NOT NULL CHECK (item_count > 0), + plan_json TEXT NOT NULL + ) + `); + db.run('CREATE INDEX IF NOT EXISTS idx_trophy_plan_context ON trophy_reconstruction_plans(context_hash, expires_at DESC)'); + db.run(`CREATE TRIGGER IF NOT EXISTS trophy_reconstruction_plans_no_update + BEFORE UPDATE ON trophy_reconstruction_plans BEGIN + SELECT RAISE(ABORT, 'trophy reconstruction plans are immutable'); + END`); + db.run(`CREATE TRIGGER IF NOT EXISTS trophy_reconstruction_plans_no_delete + BEFORE DELETE ON trophy_reconstruction_plans BEGIN + SELECT RAISE(ABORT, 'trophy reconstruction plans are immutable'); + END`); + }, +}; \ No newline at end of file diff --git a/src/database/migrations/0016-create-trophy-reconstruction-journals.js b/src/database/migrations/0016-create-trophy-reconstruction-journals.js new file mode 100644 index 0000000..b3249c9 --- /dev/null +++ b/src/database/migrations/0016-create-trophy-reconstruction-journals.js @@ -0,0 +1,56 @@ +'use strict'; + +module.exports = { + version: 16, + description: 'Create verified trophy backup manifests and reconstruction journals', + up(db) { + db.run(` + CREATE TABLE IF NOT EXISTS trophy_reconstruction_backups ( + id TEXT PRIMARY KEY, + plan_id TEXT NOT NULL, + manifest_sha256 TEXT NOT NULL UNIQUE CHECK (length(manifest_sha256) = 64), + adapter_id TEXT NOT NULL, + adapter_version TEXT NOT NULL, + created_at INTEGER NOT NULL CHECK (created_at > 0), + relative_directory TEXT NOT NULL, + manifest_json TEXT NOT NULL + ) + `); + db.run(` + CREATE TABLE IF NOT EXISTS trophy_reconstruction_runs ( + id TEXT PRIMARY KEY, + plan_id TEXT NOT NULL, + plan_sha256 TEXT NOT NULL CHECK (length(plan_sha256) = 64), + backup_id TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('prepared', 'applying', 'verifying', 'completed', 'failed', 'rolled_back', 'rollback_failed')), + started_at INTEGER NOT NULL CHECK (started_at > 0), + completed_at INTEGER, + error_message TEXT NOT NULL DEFAULT '', + FOREIGN KEY (backup_id) REFERENCES trophy_reconstruction_backups(id) + ) + `); + db.run(` + CREATE TABLE IF NOT EXISTS trophy_reconstruction_run_items ( + run_id TEXT NOT NULL, + item_key TEXT NOT NULL, + ordinal INTEGER NOT NULL CHECK (ordinal >= 0), + status TEXT NOT NULL CHECK (status IN ('pending', 'applying', 'applied', 'verified', 'failed')), + item_json TEXT NOT NULL, + result_json TEXT NOT NULL DEFAULT '{}', + error_message TEXT NOT NULL DEFAULT '', + updated_at INTEGER NOT NULL CHECK (updated_at > 0), + PRIMARY KEY (run_id, item_key), + FOREIGN KEY (run_id) REFERENCES trophy_reconstruction_runs(id) ON DELETE CASCADE + ) + `); + db.run('CREATE INDEX IF NOT EXISTS idx_trophy_runs_plan_status ON trophy_reconstruction_runs(plan_id, status, started_at DESC)'); + db.run(`CREATE TRIGGER IF NOT EXISTS trophy_reconstruction_backups_no_update + BEFORE UPDATE ON trophy_reconstruction_backups BEGIN + SELECT RAISE(ABORT, 'trophy backup manifests are immutable'); + END`); + db.run(`CREATE TRIGGER IF NOT EXISTS trophy_reconstruction_backups_no_delete + BEFORE DELETE ON trophy_reconstruction_backups BEGIN + SELECT RAISE(ABORT, 'trophy backup manifests are immutable'); + END`); + }, +}; \ No newline at end of file diff --git a/src/database/migrations/0017-create-ps5-system-backup-inventory.js b/src/database/migrations/0017-create-ps5-system-backup-inventory.js new file mode 100644 index 0000000..01009c7 --- /dev/null +++ b/src/database/migrations/0017-create-ps5-system-backup-inventory.js @@ -0,0 +1,29 @@ +'use strict'; + +module.exports = { + version: 17, + description: 'Create immutable PS5 system backup inventory', + id: '0017-create-ps5-system-backup-inventory', + up(db) { + db.run(`CREATE TABLE IF NOT EXISTS ps5_system_backup_artifacts ( + id TEXT PRIMARY KEY, + source_path TEXT NOT NULL, + source_path_sha256 TEXT NOT NULL, + display_name TEXT NOT NULL, + size_bytes INTEGER NOT NULL CHECK (size_bytes >= 0), + sha256 TEXT NOT NULL CHECK (length(sha256) = 64), + format TEXT NOT NULL, + integrity_status TEXT NOT NULL CHECK (integrity_status IN ('candidate', 'unsupported', 'corrupt')), + duplicate_of TEXT REFERENCES ps5_system_backup_artifacts(id), + read_only_verified INTEGER NOT NULL CHECK (read_only_verified = 1), + modified_at INTEGER NOT NULL, + registered_at INTEGER NOT NULL, + metadata_json TEXT NOT NULL, + checklist_json TEXT NOT NULL, + claims_json TEXT NOT NULL + )`); + db.run('CREATE INDEX IF NOT EXISTS idx_ps5_system_backup_hash ON ps5_system_backup_artifacts(sha256, registered_at)'); + db.run(`CREATE TRIGGER IF NOT EXISTS ps5_system_backup_artifacts_immutable_update BEFORE UPDATE ON ps5_system_backup_artifacts BEGIN SELECT RAISE(ABORT, 'PS5 system backup evidence is immutable'); END`); + db.run(`CREATE TRIGGER IF NOT EXISTS ps5_system_backup_artifacts_immutable_delete BEFORE DELETE ON ps5_system_backup_artifacts BEGIN SELECT RAISE(ABORT, 'PS5 system backup evidence is immutable'); END`); + }, +}; \ No newline at end of file diff --git a/src/database/migrations/0018-create-save-recovery-plans.js b/src/database/migrations/0018-create-save-recovery-plans.js new file mode 100644 index 0000000..43515ae --- /dev/null +++ b/src/database/migrations/0018-create-save-recovery-plans.js @@ -0,0 +1,26 @@ +'use strict'; + +module.exports = { + version: 18, + description: 'Create immutable save recovery plans and journals', + id: '0018-create-save-recovery-plans', + up(db) { + db.run(`CREATE TABLE IF NOT EXISTS save_recovery_plans ( + id TEXT PRIMARY KEY, plan_sha256 TEXT NOT NULL UNIQUE CHECK(length(plan_sha256)=64), title_id TEXT NOT NULL, + source_account_id TEXT NOT NULL, target_account_id TEXT NOT NULL, adapter_id TEXT NOT NULL, adapter_version TEXT NOT NULL, + created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, plan_json TEXT NOT NULL + )`); + db.run(`CREATE TABLE IF NOT EXISTS save_recovery_runs ( + id TEXT PRIMARY KEY, plan_id TEXT NOT NULL REFERENCES save_recovery_plans(id), status TEXT NOT NULL CHECK(status IN ('running','completed','failed','rolled_back','rollback_failed')), + backup_json TEXT NOT NULL DEFAULT '{}', started_at INTEGER NOT NULL, completed_at INTEGER, error_message TEXT NOT NULL DEFAULT '' + )`); + db.run(`CREATE TABLE IF NOT EXISTS save_recovery_run_steps ( + run_id TEXT NOT NULL REFERENCES save_recovery_runs(id), step_id TEXT NOT NULL, ordinal INTEGER NOT NULL, + status TEXT NOT NULL CHECK(status IN ('pending','running','completed','failed')), input_sha256 TEXT, output_sha256 TEXT, + result_json TEXT NOT NULL DEFAULT '{}', error_message TEXT NOT NULL DEFAULT '', updated_at INTEGER NOT NULL, + PRIMARY KEY(run_id, step_id), UNIQUE(run_id, ordinal) + )`); + db.run(`CREATE TRIGGER IF NOT EXISTS save_recovery_plans_immutable_update BEFORE UPDATE ON save_recovery_plans BEGIN SELECT RAISE(ABORT, 'save recovery plan is immutable'); END`); + db.run(`CREATE TRIGGER IF NOT EXISTS save_recovery_plans_immutable_delete BEFORE DELETE ON save_recovery_plans BEGIN SELECT RAISE(ABORT, 'save recovery plan is immutable'); END`); + }, +}; \ No newline at end of file diff --git a/src/database/migrations/0019-add-v3-performance-indexes.js b/src/database/migrations/0019-add-v3-performance-indexes.js new file mode 100644 index 0000000..947eff2 --- /dev/null +++ b/src/database/migrations/0019-add-v3-performance-indexes.js @@ -0,0 +1,15 @@ +'use strict'; + +module.exports = { + version: 19, + description: 'Add bounded v3 listing and trophy history indexes', + id: '0019-add-v3-performance-indexes', + up(db) { + db.run("CREATE INDEX IF NOT EXISTS idx_titles_listing_sort ON titles(COALESCE(NULLIF(sort_name,''),NULLIF(name,''),title_id) COLLATE NOCASE, id)"); + db.run("CREATE INDEX IF NOT EXISTS idx_titles_platform_sort ON titles(platform, COALESCE(NULLIF(sort_name,''),NULLIF(name,''),title_id) COLLATE NOCASE, id)"); + db.run('CREATE INDEX IF NOT EXISTS idx_local_copies_title_state_id ON local_copies(title_id, availability_state, id)'); + db.run('CREATE INDEX IF NOT EXISTS idx_console_installs_title_state_id ON console_installs(title_id, install_state, id)'); + db.run('CREATE INDEX IF NOT EXISTS idx_trophy_psn_fetched_id ON trophy_psn_snapshots(fetched_at DESC, id)'); + db.run('CREATE INDEX IF NOT EXISTS idx_trophy_local_observed_id ON trophy_local_snapshots(observed_at DESC, id)'); + }, +}; \ No newline at end of file diff --git a/src/database/migrations/0020-create-trophy-progression-plans.js b/src/database/migrations/0020-create-trophy-progression-plans.js new file mode 100644 index 0000000..583f541 --- /dev/null +++ b/src/database/migrations/0020-create-trophy-progression-plans.js @@ -0,0 +1,29 @@ +'use strict'; + +module.exports = { + version: 20, + description: 'Create immutable synthetic trophy progression plans', + up(db) { + db.run(` + CREATE TABLE IF NOT EXISTS trophy_progression_plans ( + id TEXT PRIMARY KEY, + plan_sha256 TEXT NOT NULL UNIQUE CHECK (length(plan_sha256) = 64), + mode TEXT NOT NULL CHECK (mode IN ('synthetic_unlock', 'timestamp_edit')), + catalog_key TEXT NOT NULL, + catalog_sha256 TEXT NOT NULL CHECK (length(catalog_sha256) = 64), + created_at INTEGER NOT NULL CHECK (created_at > 0), + item_count INTEGER NOT NULL CHECK (item_count > 0 AND item_count <= 128), + plan_json TEXT NOT NULL + ) + `); + db.run('CREATE INDEX IF NOT EXISTS idx_trophy_progression_created ON trophy_progression_plans(created_at DESC, id)'); + db.run(`CREATE TRIGGER IF NOT EXISTS trophy_progression_plans_no_update + BEFORE UPDATE ON trophy_progression_plans BEGIN + SELECT RAISE(ABORT, 'trophy progression plans are immutable'); + END`); + db.run(`CREATE TRIGGER IF NOT EXISTS trophy_progression_plans_no_delete + BEFORE DELETE ON trophy_progression_plans BEGIN + SELECT RAISE(ABORT, 'trophy progression plans are immutable'); + END`); + }, +}; diff --git a/src/exfat/native-exfat.js b/src/exfat/native-exfat.js new file mode 100644 index 0000000..8ecbe4b --- /dev/null +++ b/src/exfat/native-exfat.js @@ -0,0 +1,293 @@ +'use strict'; + +// ───────────────────────────────────────────────────────────────────────────── +// Native exFAT image builder (Windows) +// +// A pure Node.js port of ps5-image-studio's lazy_mkpfs/create_exfat.py Windows +// path — no external .bat/.ps1 scripts and no Python. The only external +// dependency is OSFMount (osfmount.com), a kernel mount driver, because Windows +// cannot format a raw exFAT image from userland without mounting it. +// +// Pipeline: +// 1. calculateExfatSize() — walk source, compute image size + cluster +// 2. fsutil file createnew — allocate the raw image +// 3. osfmount -a — mount the raw image as a drive letter (rw) +// 4. format /FS:exFAT — quick-format the mounted volume +// 5. streamed parallel copy — copy the game folder into the mounted volume +// 6. osfmount -d — unmount (retry, Windows is slow to release locks) +// ───────────────────────────────────────────────────────────────────────────── + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawn } = require('child_process'); + +const MB = 1024 * 1024; + +// ── Sizing (ported 1:1 from calculate_exfat_size) ──────────────────────────── +function calculateExfatSize(source, forceCluster) { + let clusterSize = forceCluster || 65536; // 64K default, matches PS5 exFAT + let totalRaw = 0, totalAlloc = 0, fileCount = 0, dirCount = 0; + + const walk = (dir) => { + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch { return; } + for (const e of entries) { + const full = path.join(dir, e.name); + if (e.isSymbolicLink()) continue; + if (e.isDirectory()) { dirCount++; walk(full); } + else if (e.isFile()) { + let size = 0; + try { size = fs.statSync(full).size; } catch { continue; } + totalRaw += size; + totalAlloc += Math.ceil(size / clusterSize) * clusterSize; + fileCount++; + } + } + }; + walk(source); + + const dataClusters = Math.ceil(totalAlloc / clusterSize); + const fatBytes = dataClusters * 4; + const bitmapBytes = Math.ceil(dataClusters / 8); + const entryBytes = (fileCount + dirCount) * 256; + const metaFixed = 32 * MB; + + const baseTotal = totalAlloc + fatBytes + bitmapBytes + entryBytes + metaFixed; + let spare = Math.floor(baseTotal / 200); + spare = Math.max(spare, 64 * MB); + spare = Math.min(spare, 512 * MB); + + let total = baseTotal + spare; + total = Math.max(total, totalRaw + 64 * MB); + + const sizeMb = Math.ceil(total / MB); + return { sizeMb, clusterSize, totalRaw, fileCount }; +} + +// ── Small process helpers ──────────────────────────────────────────────────── +function run(cmd, args, { cwd, onProc } = {}) { + return new Promise((resolve, reject) => { + const proc = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], cwd, windowsHide: true }); + onProc?.(proc); + const out = []; + proc.stdout.on('data', d => out.push(d.toString())); + proc.stderr.on('data', d => out.push(d.toString())); + proc.on('error', reject); + proc.on('close', code => { + const text = out.join(''); + if (code === 0) resolve(text); + else reject(new Error(`${path.basename(cmd)} exited ${code}: ${text.slice(0, 400)}`)); + }); + }); +} + +function resolveOsfmount(configured) { + const candidates = [ + configured, + 'C:\\Program Files\\OSFMount\\osfmount.com', + 'C:\\Program Files (x86)\\OSFMount\\osfmount.com', + ].filter(Boolean); + for (const c of candidates) { if (fs.existsSync(c)) return c; } + return null; +} + +function freeDriveLetter() { + const used = new Set(); + for (const l of 'CDEFGHIJKLMNOPQRSTUVWXYZ') { + if (fs.existsSync(`${l}:\\`)) used.add(l); + } + for (const l of 'FGHIJKLMNOPQRSTUVWXYZ') { if (!used.has(l)) return l; } + throw new Error('No free drive letters available for mounting.'); +} + +// Windows `format` rejects raw byte /A: values at this size and wants K/M. +function allocArg(clusterSize) { + return clusterSize % MB === 0 ? `${clusterSize / MB}M` : `${clusterSize / 1024}K`; +} + +const sleep = ms => new Promise(r => setTimeout(r, ms)); + +// ── Streamed parallel copy into the mounted volume ─────────────────────────── +async function copyTree(source, destRoot, { onProgress, isCancelled, onProc } = {}) { + // Collect dirs (to pre-create) and files (to copy). + const dirs = []; + const files = []; + const walk = (dir, rel) => { + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch { return; } + for (const e of entries) { + const full = path.join(dir, e.name); + const r = rel ? path.join(rel, e.name) : e.name; + if (e.isSymbolicLink()) continue; + if (e.isDirectory()) { dirs.push(r); walk(full, r); } + else if (e.isFile()) { + let size = 0; + try { size = fs.statSync(full).size; } catch { continue; } + files.push({ src: full, rel: r, size }); + } + } + }; + walk(source, ''); + + for (const d of dirs) fs.mkdirSync(path.join(destRoot, d), { recursive: true }); + + let copiedFiles = 0; + const copyOne = (file) => new Promise((resolve, reject) => { + if (isCancelled?.()) return reject(new Error('Cancelled')); + const dst = path.join(destRoot, file.rel); + fs.mkdirSync(path.dirname(dst), { recursive: true }); + const rs = fs.createReadStream(file.src, { highWaterMark: 8 * MB }); + const ws = fs.createWriteStream(dst); + onProc?.({ kill: () => { rs.destroy(); ws.destroy(); } }); // let cancel tear down the active stream + let cancelled = false; + rs.on('data', chunk => { + onProgress?.({ bytes: chunk.length, file: file.rel }); + if (isCancelled?.() && !cancelled) { cancelled = true; rs.destroy(); ws.destroy(); reject(new Error('Cancelled')); } + }); + rs.on('error', reject); + ws.on('error', reject); + ws.on('finish', () => { copiedFiles++; onProgress?.({ files: 1 }); resolve(); }); + rs.pipe(ws); + }); + + // Bounded concurrency pool. + const concurrency = Math.min(Math.max(os.cpus().length, 2), 8); + let idx = 0; + const worker = async () => { + while (idx < files.length) { + if (isCancelled?.()) throw new Error('Cancelled'); + const file = files[idx++]; + await copyOne(file); + } + }; + await Promise.all(Array.from({ length: concurrency }, worker)); + return { copiedFiles, totalFiles: files.length }; +} + +// ── Main entry ─────────────────────────────────────────────────────────────── +// opts: { sourceDir, outputFile, label, clusterSize, osfmountPath, +// onLog(str), onProgress({bytes,files,file,phase}), isCancelled(), onProc(proc) } +async function createExfatImage(opts) { + if (process.platform !== 'win32') { + throw new Error('Native exFAT builder currently supports Windows only.'); + } + const { + sourceDir, outputFile, label = 'PS5exfat', clusterSize = null, + osfmountPath = '', onLog = () => {}, onProgress = () => {}, isCancelled = () => false, onProc, + } = opts; + + const source = path.resolve(sourceDir); + if (!fs.existsSync(source) || !fs.statSync(source).isDirectory()) { + throw new Error(`Source folder does not exist: ${source}`); + } + const osfmount = resolveOsfmount(osfmountPath); + if (!osfmount) { + throw new Error('OSFMount not found. Install OSFMount and set its path in Settings → Game Conversion.'); + } + + let output = path.resolve(outputFile); + if (path.extname(output).toLowerCase() !== '.exfat') output += '.exfat'; + fs.mkdirSync(path.dirname(output), { recursive: true }); + if (fs.existsSync(output)) fs.unlinkSync(output); + + onProgress({ phase: 'Analyzing folder…' }); + const { sizeMb, clusterSize: cluster, totalRaw, fileCount } = calculateExfatSize(source, clusterSize); + onLog(`Analyzing: ${fileCount} files, ${(totalRaw / MB).toFixed(1)} MB`); + onLog(`Target image: ${sizeMb} MB (cluster ${cluster / 1024}K)`); + + const sizeBytes = sizeMb * MB; + let mounted = false; + let driveLetter = null; + const mountPoint = () => `${driveLetter}:`; + + try { + // 1. Allocate raw image + onProgress({ phase: 'Allocating image…', total: totalRaw }); + onLog(`Creating raw image (${(sizeBytes / MB).toFixed(0)} MB)…`); + await run('fsutil', ['file', 'createnew', output, String(sizeBytes)], { onProc }); + const actual = fs.statSync(output).size; + if (actual !== sizeBytes) throw new Error(`Image size mismatch: expected ${sizeBytes}, got ${actual}`); + if (isCancelled()) throw new Error('Cancelled'); + + // 2. Mount via OSFMount + driveLetter = freeDriveLetter(); + onProgress({ phase: `Mounting as ${mountPoint()}…` }); + onLog(`Mounting raw image as ${mountPoint()} via OSFMount…`); + await run(osfmount, ['-a', '-t', 'file', '-f', output, '-m', mountPoint(), '-o', 'rw'], { onProc }); + mounted = true; + if (isCancelled()) throw new Error('Cancelled'); + + // 3. Format as exFAT (quick). `format` is a .com that prompts even with /Y — + // wrap in cmd.exe and feed newlines to stdin, mirroring the reference tool. + onProgress({ phase: 'Formatting exFAT…' }); + onLog(`Formatting ${mountPoint()} as exFAT (cluster ${allocArg(cluster)}, label '${label}')…`); + await new Promise((resolve, reject) => { + const fmt = spawn('cmd.exe', + ['/c', 'format', mountPoint(), '/FS:exFAT', '/Q', '/Y', `/A:${allocArg(cluster)}`, `/V:${label}`], + { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }); + onProc?.(fmt); + const out = []; + fmt.stdout.on('data', d => out.push(d.toString())); + fmt.stderr.on('data', d => out.push(d.toString())); + try { fmt.stdin.write('\n\n\n'); fmt.stdin.end(); } catch {} + fmt.on('error', reject); + fmt.on('close', code => code === 0 + ? resolve() + : reject(new Error(`format /FS:exFAT failed (rc=${code}): ${out.join('').slice(0, 300)}`))); + }); + if (isCancelled()) throw new Error('Cancelled'); + + // 4. Copy files into the mounted volume root (needs trailing separator). + onProgress({ phase: 'Copying files…', total: totalRaw }); + onLog(`Copying ${fileCount} files (${(totalRaw / MB).toFixed(1)} MB)…`); + const destRoot = `${driveLetter}:\\`; + await copyTree(source, destRoot, { onProgress, isCancelled, onProc }); + onLog('Copy complete.'); + } catch (err) { + // Clean up partial output on failure (after unmount below). + throw err; + } finally { + // 5. Unmount, retrying — Windows is slow to release volume locks. + if (mounted && driveLetter) { + onLog('Syncing and unmounting…'); + let ok = false; + for (let i = 0; i < 5; i++) { + try { await run(osfmount, ['-d', '-m', mountPoint()], {}); ok = true; break; } + catch { await sleep(1000); } + } + if (!ok) onLog('⚠ OSFMount unmount failed (volume may be locked by another process).'); + } + } + + // Remove the image if the build was cancelled/failed partway. + if (isCancelled()) { + try { if (fs.existsSync(output)) fs.unlinkSync(output); } catch {} + throw new Error('Cancelled'); + } + + onLog(`✅ Created exFAT image: ${output}`); + return { output, sizeMb, cluster, totalRaw, fileCount }; +} + +module.exports = { createExfatImage, calculateExfatSize }; + +// Self-check for the sizing math: node src/exfat/native-exfat.js --selfcheck +if (require.main === module && process.argv.includes('--selfcheck')) { + const assert = require('assert'); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'exfat-sc-')); + fs.writeFileSync(path.join(tmp, 'a.bin'), Buffer.alloc(1 * MB)); + fs.mkdirSync(path.join(tmp, 'sub')); + fs.writeFileSync(path.join(tmp, 'sub', 'b.bin'), Buffer.alloc(3 * MB)); + const r = calculateExfatSize(tmp, null); + assert.strictEqual(r.fileCount, 2, 'fileCount'); + assert.strictEqual(r.totalRaw, 4 * MB, 'totalRaw'); + assert.strictEqual(r.clusterSize, 65536, 'default cluster 64K'); + assert.ok(r.sizeMb >= Math.ceil((4 * MB + 64 * MB) / MB), 'size >= raw + 64MB floor'); + assert.strictEqual(allocArg(65536), '64K'); + assert.strictEqual(allocArg(1 * MB), '1M'); + fs.rmSync(tmp, { recursive: true, force: true }); + console.log('native-exfat selfcheck OK:', r); +} diff --git a/src/ftp.js b/src/ftp.js new file mode 100644 index 0000000..7617baa --- /dev/null +++ b/src/ftp.js @@ -0,0 +1,845 @@ +'use strict'; + +const ftp = require('basic-ftp'); +const path = require('path'); +const fs = require('fs'); +const stream = require('stream'); +const log = require('electron-log'); +const { inspectFtpScanRoot } = require('./services/ftp-scan-root-coverage'); +let client = null; +let connected = false; +let _lastCreds = null; // stored so dropped connections can be silently re-established + +// Promise-chaining mutex — basic-ftp rejects concurrent operations on the same client. +// All public functions that use the singleton `client` must go through withLock(). +let _lock = Promise.resolve(); + +function withLock(fn) { + const p = _lock.then(fn); + _lock = p.catch(() => {}); // keep chain alive even when fn rejects + return p; +} + +/** + * Must be called from inside withLock(). + * Silently re-establishes the FTP control connection when the PS5's FTPD has + * closed it between transfers (e.g. idle timeout, post-transfer disconnect). + * Only reconnects if we have stored credentials from a previous connect() call. + */ +async function _ensureActive() { + if (client && !client.closed) { + // Liveness check: PWD gets an instant response on a live connection. + // Race against 5 s to detect half-open / silently-dropped control sockets — + // basic-ftp's client.closed is NOT updated when the PS5 FTPD closes server-side, + // so without this check every control-channel command would hang indefinitely. + try { + await Promise.race([ + client.pwd(), + new Promise((_, rej) => setTimeout(() => rej(new Error('liveness timeout')), 5000)), + ]); + return; // still live + } catch (_) { + log.warn('[FTP] Liveness check failed — forcing reconnect'); + try { client.close(); } catch (_e) {} + client = null; + } + } + if (!_lastCreds) throw new Error('Not connected to FTP. Connect to your PS5 first.'); + log.info('[FTP] Control connection dropped — auto-reconnecting…'); + if (client) { try { client.close(); } catch (_) {} } + client = new ftp.Client(30000); + client.ftp.verbose = false; + await client.access({ + host: _lastCreds.host, + port: parseInt(_lastCreds.port) || 21, + user: _lastCreds.user || 'anonymous', + password: _lastCreds.password || '', + secure: false, + }); + connected = true; + log.info('[FTP] Auto-reconnected to PS5'); +} + +// ── SFO Parser ──────────────────────────────────────────────────────────────── +// Parses PS5 param.sfo binary files to extract game metadata. +// SFO format: magic(4) + version(4) + key_table_off(4) + data_table_off(4) + num_entries(4) +// Followed by index table (16 bytes/entry), key table, and data table. + +function parseSfo(buf) { + const result = { title: '', game_id: '', version: '', content_id: '' }; + try { + if (buf.length < 20 || buf.readUInt32LE(0) !== 0x46535000) return result; // "\0PSF" + + const keyOff = buf.readUInt32LE(8); + const dataOff = buf.readUInt32LE(12); + const count = buf.readUInt32LE(16); + + for (let i = 0; i < count; i++) { + const e = 20 + i * 16; + const kOff = buf.readUInt16LE(e); + const fmt = buf.readUInt16LE(e + 2); + const dataLen = buf.readUInt32LE(e + 4); + const dOff = buf.readUInt32LE(e + 12); + + // Read null-terminated key + let kEnd = keyOff + kOff; + while (kEnd < buf.length && buf[kEnd] !== 0) kEnd++; + const key = buf.slice(keyOff + kOff, kEnd).toString('utf8'); + + // Read value + const dStart = dataOff + dOff; + let value = ''; + if (fmt === 0x0204 || fmt === 0x0004) { + value = buf.slice(dStart, dStart + dataLen).toString('utf8').replace(/\0/g, '').trim(); + } else if (fmt === 0x0404) { + value = buf.readUInt32LE(dStart).toString(); + } + + if (key === 'TITLE') result.title = value; + if (key === 'TITLE_ID') result.game_id = value; + if (key === 'VERSION' || key === 'APP_VER') result.version = value; + if (key === 'CONTENT_ID') result.content_id = value; + } + } catch (e) { + log.warn('[FTP] SFO parse error:', e.message); + } + return result; +} + +// ── Download to buffer ──────────────────────────────────────────────────────── + +async function downloadToBuffer(c, remotePath) { + const chunks = []; + const writable = new stream.Writable({ + write(chunk, _enc, cb) { chunks.push(Buffer.from(chunk)); cb(); }, + }); + await c.downloadTo(writable, remotePath); + return Buffer.concat(chunks); +} + +// ── Connection ──────────────────────────────────────────────────────────────── + +async function connect({ host, port, user, password }) { + if (client) { try { client.close(); } catch (_) {} } + _lock = Promise.resolve(); // reset lock — discard any queued ops for the old client + + // 30-second idle timeout. basic-ftp applies this to the data socket too, so it only + // fires when NO bytes have flowed for 30s — i.e. a truly stalled connection. + // During an active transfer the socket is continuously receiving/sending, so this + // timer resets on every chunk and never fires while data is moving. + client = new ftp.Client(30000); + client.ftp.verbose = false; + + await client.access({ + host, + port: parseInt(port) || 21, + user: user || 'anonymous', + password: password || '', + secure: false, + }); + + _lastCreds = { host, port, user, password }; + connected = true; + log.info(`[FTP] Connected to ${host}:${port}`); +} + +async function disconnect() { + if (client) { client.close(); client = null; } + connected = false; + _lastCreds = null; // clear creds so _ensureActive won't reconnect after explicit disconnect + _lock = Promise.resolve(); + log.info('[FTP] Disconnected'); +} + +function isConnected() { + return connected && client != null && !client.closed; +} + +/** True if we have stored credentials that _ensureActive() can use to reconnect. */ +function hasCredentials() { + return _lastCreds !== null; +} + +// ── Game Scanner ────────────────────────────────────────────────────────────── +// PS5 FTPD typically exposes game folders under /mnt/sandbox/pfsmnt/ +// Each game directory is named like PPSA#####-app0 +let scanGeneration = 0; + +const SCAN_PATHS = [ + '/mnt/sandbox/pfsmnt/', + '/user/app/', + '/mnt/data/app/', +]; + +async function scanGames(onProgress, customPaths) { + if (!connected && !_lastCreds) throw new Error('Not connected to FTP. Connect to your PS5 first.'); + return withLock(async () => { + await _ensureActive(); + const c = client; + const pathsToScan = (customPaths && customPaths.length > 0) + ? customPaths.map(p => p.endsWith('/') ? p : p + '/') + : SCAN_PATHS; + const startedAt = new Date(); + const coverage = []; + const games = []; + const generation = ++scanGeneration; + + for (const basePath of pathsToScan) { + const normalizedRoot = basePath.endsWith('/') ? basePath : `${basePath}/`; + const rootEntry = { + root: normalizedRoot, + coverage_state: 'inaccessible', + started_at: new Date().toISOString(), + observed_games: 0, + observed_game_ids: [], + }; + coverage.push(rootEntry); + + const inspected = await inspectFtpScanRoot(c, normalizedRoot); + rootEntry.coverage_state = inspected.coverageState; + rootEntry.namespace_state = inspected.namespaceState; + rootEntry.coverage_evidence = inspected.evidence; + if (inspected.evidenceRoot) rootEntry.evidence_root = inspected.evidenceRoot; + if (inspected.missingSegment) rootEntry.missing_segment = inspected.missingSegment; + if (inspected.directError) rootEntry.direct_error = inspected.directError; + if (inspected.error) rootEntry.last_error = inspected.error; + if (inspected.coverageState !== 'complete') continue; + const list = inspected.entries; + + for (const item of list) { + if (item.type !== ftp.FileType.Directory) continue; + + // Match title ID pattern anywhere in the folder name + // e.g. "PPSA03644-app", "PPSA03644", "Two Point Campus 01.029 PPSA03644" + const match = item.name.match(/([A-Z]{4}\d{5})/); + if (!match) continue; + + const ftpPath = normalizedRoot + item.name; + const game = { + game_id: match[1], + title: match[1], + content_id: '', + size: item.size || 0, + ftp_path: ftpPath, + scan_root: normalizedRoot, + last_scanned: startedAt.toISOString(), + }; + + // Try to enrich from param.sfo + for (const sfoRel of ['/sce_sys/param.sfo', '/param.sfo']) { + try { + const buf = await downloadToBuffer(c, ftpPath + sfoRel); + const sfo = parseSfo(buf); + if (sfo.title) game.title = sfo.title; + if (sfo.version) game.version = sfo.version; + if (sfo.content_id) game.content_id = sfo.content_id; + if (sfo.game_id) game.game_id = sfo.game_id; + break; + } catch (_) {} + } + + games.push(game); + rootEntry.observed_games += 1; + rootEntry.observed_game_ids.push(game.game_id); + if (onProgress) onProgress({ current: games.length, game }); + } + } + + const completedAt = new Date(); + const result = { + scan_id: `ftp-${generation}-${completedAt.getTime()}`, + generation, + provider: 'ftp', + started_at: startedAt.toISOString(), + completed_at: completedAt.toISOString(), + root_coverage: coverage, + games, + count: games.length, + }; + log.info(`[FTP] Scan complete — found ${games.length} games`); + return result; + }); // end withLock +} + +// ── Internal transfer helpers (accept explicit client) ──────────────────────── + +async function _downloadFileWith(c, remotePath, localPath, onProgress, startAt = 0) { + c.trackProgress(info => { if (onProgress) onProgress(info); }); + try { + await c.downloadTo(localPath, remotePath, Math.max(0, Number(startAt) || 0)); + } finally { + c.trackProgress(); + } +} + +// Ensure every component of a remote path exists by issuing MKD for each level. +// MKD is safe to call on existing dirs — PS5 FTPD returns 550 which sendIgnoringError swallows. +async function _ensureRemoteDir(c, remoteDir) { + const parts = remoteDir.replace(/^\/+/, '').split('/'); + let current = ''; + for (const part of parts) { + if (!part) continue; + current += '/' + part; + try { await c.sendIgnoringError('MKD ' + current); } catch (_) {} + } +} + +async function _uploadFileWith(c, localPath, remotePath, onProgress) { + // Ensure the remote parent directory exists before uploading. + const remoteDir = remotePath.lastIndexOf('/') > 0 + ? remotePath.substring(0, remotePath.lastIndexOf('/')) + : ''; + if (remoteDir) await _ensureRemoteDir(c, remoteDir); + // basic-ftp's trackProgress does not include the file size in its callback, + // so we stat it upfront and inject it into every progress event ourselves. + let fileSize = 0; + try { fileSize = fs.statSync(localPath).size; } catch (_) {} + c.trackProgress(info => { if (onProgress) onProgress({ ...info, size: fileSize }); }); + try { + await c.uploadFrom(localPath, remotePath); + } finally { + c.trackProgress(); + } +} + +// Upload an entire directory's contents into a remote directory (recursive). +// Fires onProgress({ file, bytesOverall, filesDone, filesTotal }) for each file. +// hooks: { onStart({ count, totalBytes }), onRetry({ rel, attempt, maxAttempts, error }), +// onFileFailed({ rel, attempts, error }) } +// +// APPROACH: flat scan → MKD pre-pass → per-file cd(absolute)+STOR(filename) +// • No recursive CWD stack / no cdup() — a single failed CDUP can abort the +// entire upload on PS5 FTPD (proven root cause of the "13 files" cutoff). +// • Every file independently navigates to its target directory via absolute +// CWD so a previous failure can never leave CWD in the wrong place. +// • MKD pre-pass (sendIgnoringError) creates all subdirs before any transfer +// starts so the cd() per file always lands in an existing directory. +async function _uploadDirectoryWith(c, localDir, remoteDir, onProgress, hooks = {}) { + // ── Active client ref — refreshed on reconnect ───────────────────────────── + // hooks._reconnect() (if provided) closes the stale client, reconnects, and + // returns the fresh client object. Callers (uploadDirectory / createClient) + // supply this callback so we can recover from dead control-channel sockets + // without leaking the reconnect logic into this general-purpose function. + const _reconnect = hooks._reconnect || null; + let ac = c; // "active client" — may be swapped after a reconnect + + async function tryRefreshClient() { + if (!_reconnect) return; + try { + try { ac.close(); } catch (_) {} // cancel any ghost operations on the stale client + const fresh = await _reconnect(); + if (fresh) { ac = fresh; log.info('[FTP] uploadDirectory: client refreshed after error'); } + } catch (_re) { + log.warn(`[FTP] uploadDirectory: client refresh failed: ${_re.message}`); + } + } + + // ── PHASE 1: flat-scan local tree ────────────────────────────────────────── + const allFiles = []; // { localFull, remoteFileDir, remoteFileName, rel } + const allDirs = new Set([remoteDir]); + + function scanLocal(localPath, remotePath) { + let entries; + try { entries = fs.readdirSync(localPath, { withFileTypes: true }); } + catch (_) { return; } + for (const e of entries) { + const localFull = path.join(localPath, e.name); + const remoteFull = remotePath + '/' + e.name; + if (e.isDirectory()) { + allDirs.add(remoteFull); + scanLocal(localFull, remoteFull); + } else { + allFiles.push({ + localFull, + remoteFileDir: remotePath, + remoteFileName: e.name, + rel: remoteFull.slice(remoteDir.length + 1), + }); + } + } + } + + scanLocal(localDir, remoteDir); + + const filesTotal = allFiles.length; + const totalBytes = allFiles.reduce((sum, { localFull }) => { + try { return sum + fs.statSync(localFull).size; } catch (_) { return sum; } + }, 0); + + log.info(`[FTP] uploadDirectory: localDir="${localDir}" remoteDir="${remoteDir}" filesFound=${filesTotal} dirs=${allDirs.size}`); + + try { hooks.onStart?.({ count: filesTotal, totalBytes }); } catch (_) {} + + // ── PHASE 2: pre-create all remote directories (shallow-first) ───────────── + // Wrap in a 10-second race: a hung MKD means the control channel is dead. + // If it times out, tryRefreshClient reconnects before the upload loop starts. + const sortedDirs = [...allDirs].sort((a, b) => a.split('/').length - b.split('/').length); + log.info(`[FTP] MKD pre-pass for ${sortedDirs.length} dirs`); + for (const dir of sortedDirs) { + for (let mkdAttempt = 1; mkdAttempt <= 2; mkdAttempt++) { + try { + await Promise.race([ + ac.sendIgnoringError('MKD ' + dir), + new Promise((_, rej) => setTimeout(() => rej(new Error('MKD timeout')), 10000)), + ]); + break; // success + } catch (mkdErr) { + log.warn(`[FTP] MKD attempt ${mkdAttempt}/2 threw: ${dir} — ${mkdErr.message}`); + if (mkdAttempt < 2) await tryRefreshClient(); + } + } + } + + // ── PHASE 3: upload files — each one navigates its own absolute remote dir ── + // cd + uploadFrom are both wrapped in a 30-second race. basic-ftp has no + // command-level timeout on the control channel; a half-open TCP socket causes + // CWD / STOR to hang forever. On timeout (or any other network error) we + // refresh the client so the retry gets a clean connection. + let filesDone = 0; + const MAX_RETRIES = 3; + + // ── PHASE 3: parallel file upload worker pool ───────────────────────────── + // hooks.fileConcurrency workers each own a separate FTP connection and consume + // from a shared queue so N files transfer simultaneously. fileConcurrency=1 + // gives the same sequential behaviour as before. + const fileQueue = [...allFiles]; // workers consume via .shift() — safe in single-threaded JS + + // Primary slot uses `ac` (already live after Phase 2). + // Extra slots get fresh raw basic-ftp Client objects from the hook. + const workerStates = [{ ac, isExtra: false }]; + const fileConcurrency = Math.max(1, hooks.fileConcurrency || 1); + if (fileConcurrency > 1 && hooks._createWorkerClient) { + for (let i = 1; i < fileConcurrency; i++) { + try { + const raw = await hooks._createWorkerClient(); + workerStates.push({ ac: raw, isExtra: true }); + } catch (e) { + log.warn(`[FTP] File worker ${i} connect failed — skipping: ${e.message}`); + } + } + } + + async function runFileWorker(state) { + while (true) { + const item = fileQueue.shift(); + if (!item) return; // queue exhausted + const { localFull, remoteFileDir, remoteFileName, rel } = item; + let currentFileSize = 0; + try { currentFileSize = fs.statSync(localFull).size; } catch (_) {} + log.info(`[FTP] Uploading [${filesDone + 1}/${filesTotal}] "${rel}" → cd("${remoteFileDir}") + STOR "${remoteFileName}"`); + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + state.ac.trackProgress(info => { + if (onProgress) onProgress({ file: rel, filesDone: filesDone + 1, filesTotal, currentFileSize, ...info }); + }); + try { + // Absolute CWD + filename-only STOR — only reliable combo on PS5 FTPD. + await Promise.race([ + (async () => { + await state.ac.cd(remoteFileDir); + await state.ac.uploadFrom(localFull, remoteFileName); + })(), + new Promise((_, rej) => setTimeout(() => rej(new Error('upload timeout')), 30000)), + ]); + break; // success + } catch (uploadErr) { + if (uploadErr.message === 'Cancelled') throw uploadErr; + log.warn(`[FTP] attempt ${attempt}/${MAX_RETRIES} FAILED for "${rel}": ${uploadErr.message}`); + if (state.isExtra && hooks._createWorkerClient) { + // Replace this extra worker's client with a fresh connection. + try { state.ac.close(); } catch (_) {} + try { state.ac = await hooks._createWorkerClient(); } catch (re) { + log.warn(`[FTP] Extra worker reconnect failed: ${re.message}`); + } + } else { + // Primary client: use the shared reconnect hook (updates outer `ac`). + await tryRefreshClient(); + state.ac = ac; + } + if (attempt < MAX_RETRIES) { + try { hooks.onRetry?.({ rel, attempt, maxAttempts: MAX_RETRIES, error: uploadErr.message }); } catch (_) {} + await new Promise(r => setTimeout(r, attempt * 1000)); + } else { + try { hooks.onFileFailed?.({ rel, attempts: MAX_RETRIES, error: uploadErr.message }); } catch (_) {} + log.error(`[FTP] Giving up on "${rel}" after ${MAX_RETRIES} attempts: ${uploadErr.message}`); + // Do NOT throw — skip and continue with the rest + } + } finally { + state.ac.trackProgress(); + } + } + filesDone++; + } + } + + try { + await Promise.all(workerStates.map(s => runFileWorker(s))); + } finally { + // Close extra worker connections — primary is managed by the caller. + for (const s of workerStates) { + if (s.isExtra) try { s.ac.close(); } catch (_) {} + } + } + + // Final progress event so UI shows the completed count. + if (onProgress && filesTotal > 0) { + try { onProgress({ file: '', filesDone: filesTotal, filesTotal, bytes: 0, bytesOverall: 0 }); } catch (_) {} + } + + log.info(`[FTP] uploadDirectory done — ${filesDone}/${filesTotal} file(s) to ${remoteDir}`); +} + +// Download an entire remote directory tree into a local directory (recursive). +// Fires onProgress({ file, filesDone, filesTotal, bytes, bytesOverall }) for each chunk. +async function _downloadDirectoryWith(c, remoteDir, localDir, onProgress) { + // Walk the remote tree first so we know the total file count upfront. + async function collectRemoteFiles(rDir, relBase) { + let list; + try { list = await c.list(rDir); } catch (_) { return []; } + const out = []; + for (const entry of list) { + const rel = relBase ? `${relBase}/${entry.name}` : entry.name; + if (entry.type === ftp.FileType.Directory) { + out.push(...await collectRemoteFiles(`${rDir}/${entry.name}`, rel)); + } else { + out.push({ remotePath: `${rDir}/${entry.name}`, rel, size: entry.size || 0 }); + } + } + return out; + } + + const files = await collectRemoteFiles(remoteDir, ''); + let filesDone = 0; + let filesFailed = 0; + let bytesOverall = 0; + + for (const { remotePath, rel, size } of files) { + // Build the local path, converting forward-slash separators to OS separators. + const localPath = path.join(localDir, ...rel.split('/')); + fs.mkdirSync(path.dirname(localPath), { recursive: true }); + + // Report 1-indexed "current file" so the UI shows N/N not (N-1)/N + let startAt = 0; + try { + const existing = fs.statSync(localPath).size; + if (size > 0 && existing === size) { + bytesOverall += size; + filesDone++; + if (onProgress) onProgress({ file: rel, filesDone, filesTotal: files.length, bytes: size, bytesOverall }); + continue; + } + if (existing > 0 && (size <= 0 || existing < size)) startAt = existing; + } catch (_) {} + c.trackProgress(info => { + if (onProgress) onProgress({ + file: rel, + filesDone: filesDone + 1, + filesTotal: files.length, + bytes: info.bytes || 0, + bytesOverall: bytesOverall + startAt + (info.bytes || 0), + }); + }); + try { + await c.downloadTo(localPath, remotePath, startAt); + } catch (dlErr) { + filesFailed++; + log.warn(`[FTP] downloadDirectory: skipping ${rel} — ${dlErr.message}`); + } finally { + c.trackProgress(); // clear tracker between files + } + bytesOverall += size; + filesDone++; + } + + // Fire a final progress event so the UI reflects the true completed count + if (onProgress && files.length > 0) { + try { onProgress({ file: '', filesDone: files.length, filesTotal: files.length, bytes: 0, bytesOverall }); } catch (_) {} + } + + log.info(`[FTP] downloadDirectory done — ${filesDone - filesFailed}/${files.length} file(s) from ${remoteDir}${filesFailed ? `, ${filesFailed} skipped` : ''}`); +} + +// ── Public transfer API (uses singleton client) ─────────────────────────────── + +async function downloadFile(remotePath, localPath, onProgress, startAt = 0) { + if (!connected && !_lastCreds) throw new Error('Not connected to FTP. Connect to your PS5 first.'); + return withLock(async () => { + await _ensureActive(); + return _downloadFileWith(client, remotePath, localPath, onProgress, startAt); + }); +} + +// Fetch only the first `maxBytes` of a remote file. Used to grab enough of a +// webm clip to decode the first video frame without downloading the full file. +// After the partial download the control channel is in an unknown state so the +// client is force-closed; _ensureActive() will reconnect on the next operation. +async function downloadPartialFile(remotePath, localPath, maxBytes) { + if (!connected && !_lastCreds) throw new Error('Not connected to FTP. Connect to your PS5 first.'); + return withLock(async () => { + await _ensureActive(); + + let bytesWritten = 0; + let limitReached = false; + const fileStream = fs.createWriteStream(localPath); + + const limiter = new stream.Writable({ + write(chunk, _enc, cb) { + if (limitReached) { cb(); return; } + const remaining = maxBytes - bytesWritten; + const slice = chunk.length <= remaining ? chunk : chunk.slice(0, remaining); + bytesWritten += slice.length; + fileStream.write(slice, () => { + if (bytesWritten >= maxBytes) { + limitReached = true; + fileStream.end(); + // Destroy triggers an error in basic-ftp which aborts the data transfer. + // We intentionally swallow that error below. + this.destroy(new Error('partial-limit-reached')); + } + cb(); + }); + }, + final(cb) { fileStream.end(cb); }, + }); + + try { + await client.downloadTo(limiter, remotePath); + } catch (err) { + if (!limitReached) { + // Unexpected error — surface it + try { fileStream.destroy(); } catch (_) {} + throw err; + } + // Intentional abort — fall through + } finally { + // Always force-reconnect after a partial download so the next FTP + // operation gets a clean control channel via _ensureActive(). + try { client.close(); } catch (_) {} + client = null; + } + + // Wait for the file write to flush if not already closed + if (!fileStream.closed && !fileStream.destroyed) { + await new Promise((res, rej) => { + fileStream.once('finish', res); + fileStream.once('error', rej); + if (!fileStream.writableEnded) fileStream.end(); + }); + } + }); +} + +async function uploadFile(localPath, remotePath, onProgress) { + if (!connected && !_lastCreds) throw new Error('Not connected to FTP. Connect to your PS5 first.'); + return withLock(async () => { + await _ensureActive(); + return _uploadFileWith(client, localPath, remotePath, onProgress); + }); +} + +async function uploadDirectory(localDir, remoteDir, onProgress, hooks) { + if (!connected && !_lastCreds) throw new Error('Not connected to FTP. Connect to your PS5 first.'); + return withLock(async () => { + await _ensureActive(); + // Provide a reconnect callback so _uploadDirectoryWith can recover mid-upload + // from a dropped control-channel socket without hanging the whole queue. + const _reconnect = async () => { + await _ensureActive(); // reconnects singleton `client` if needed + return client; + }; + return _uploadDirectoryWith(client, localDir, remoteDir, onProgress, { ...hooks, _reconnect }); + }); +} + +async function downloadDirectory(remoteDir, localDir, onProgress) { + if (!connected && !_lastCreds) throw new Error('Not connected to FTP. Connect to your PS5 first.'); + return withLock(async () => { + await _ensureActive(); + return _downloadDirectoryWith(client, remoteDir, localDir, onProgress); + }); +} + +async function listRemote(remotePath) { + if (!connected && !_lastCreds) throw new Error('Not connected to FTP. Connect to your PS5 first.'); + return withLock(async () => { + await _ensureActive(); + try { + const list = await client.list(remotePath); + return list + .filter(f => f.type === ftp.FileType.File) + .map(f => ({ name: f.name, size: f.size || 0 })); + } catch (e) { + log.warn(`[FTP] listRemote failed for ${remotePath}: ${e.message}`); + return []; + } + }); +} + +async function deleteRemote(remotePath) { + if (!connected && !_lastCreds) throw new Error('Not connected to FTP. Connect to your PS5 first.'); + return withLock(async () => { + await _ensureActive(); + await client.remove(remotePath); + log.info(`[FTP] Deleted remote file: ${remotePath}`); + }); +} + +async function renameRemote(sourcePath, destinationPath) { + if (!connected && !_lastCreds) throw new Error('Not connected to FTP. Connect to your PS5 first.'); + return withLock(async () => { + await _ensureActive(); + await client.rename(sourcePath, destinationPath); + log.info(`[FTP] Renamed remote path: ${sourcePath} -> ${destinationPath}`); + return { ok: true, sourcePath, destinationPath }; + }); +} + +// ── FTP directory browser ───────────────────────────────────────────────────── + +async function removeDir(remotePath) { + if (!connected && !_lastCreds) throw new Error('Not connected to FTP. Connect to your PS5 first.'); + return withLock(async () => { + await _ensureActive(); + await client.removeDir(remotePath); + log.info(`[FTP] Removed directory: ${remotePath}`); + }); +} + +async function listDirectoryWithCwd(ftpClient, remotePath) { + const previousPath = await ftpClient.pwd(); + let primaryError = null; + try { + await ftpClient.cd(remotePath); + return await ftpClient.list(); + } catch (error) { + primaryError = error; + throw error; + } finally { + try { + await ftpClient.cd(previousPath); + } catch (restoreError) { + if (!primaryError) throw restoreError; + log.warn(`[FTP] Failed to restore working directory after list error: ${restoreError.message}`); + } + } +} + +async function listDir(remotePath) { + if (!connected && !_lastCreds) throw new Error('Not connected to FTP. Connect to your PS5 first.'); + return withLock(async () => { + await _ensureActive(); + try { + // Some PS5 FTP payloads accept `LIST ` but still return the current + // directory. An exact CWD makes the requested directory unambiguous; the + // singleton mutex prevents another operation observing the temporary cwd. + const list = await listDirectoryWithCwd(client, remotePath); + return list + .map(f => ({ + name: f.name, + isDir: f.type === ftp.FileType.Directory, + size: f.size || 0, + modifiedAt: f.modifiedAt || null, // Date | null + })) + .sort((a, b) => { + if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; + return a.name.localeCompare(b.name); + }); + } catch (e) { + log.info(`[FTP] listDir failed for ${remotePath}: ${e.message}`); + throw e; + } + }); +} + +// ── Raw client factory (for parallel file workers inside _uploadDirectoryWith) ── +// Returns the raw basic-ftp Client object so _uploadDirectoryWith can use +// cd / uploadFrom / trackProgress directly. Caller must call .close() when done. +async function createRawClient({ host, port, user, password }) { + const c = new ftp.Client(30000); + c.ftp.verbose = false; + await c.access({ + host, + port: parseInt(port) || 21, + user: user || 'anonymous', + password: password || '', + secure: false, + }); + log.info(`[FTP] Raw worker client connected to ${host}:${port}`); + return c; +} + +// ── Extra client factory (for concurrent transfer slots) ────────────────────── +// Creates a new, independently-connected FTP client from the provided credentials. +// Callers are responsible for calling .close() when done. + +async function createClient({ host, port, user, password }) { + const c = new ftp.Client(30000); // idle timeout — resets on every chunk, so safe for large files + c.ftp.verbose = false; + await c.access({ + host, + port: parseInt(port) || 21, + user: user || 'anonymous', + password: password || '', + secure: false, + }); + log.info(`[FTP] Extra client connected to ${host}:${port}`); + return { + uploadFile: (local, remote, onProg) => _uploadFileWith(c, local, remote, onProg), + uploadDirectory: (localDir, remoteDir, onProg, hooks) => { + // Reconnect callback for the dedicated client: close + re-access using the + // same credentials captured in the createClient() closure. + const _reconnect = async () => { + try { c.close(); } catch (_) {} + await c.access({ host, port: parseInt(port) || 21, user: user || 'anonymous', password: password || '', secure: false }); + log.info(`[FTP] Extra client reconnected to ${host}:${port}`); + return c; // same object — re-used after re-access + }; + return _uploadDirectoryWith(c, localDir, remoteDir, onProg, { ...hooks, _reconnect }); + }, + downloadFile: (remote, local, onProg, startAt = 0) => _downloadFileWith(c, remote, local, onProg, startAt), + downloadDirectory: (remote, local, onProg) => _downloadDirectoryWith(c, remote, local, onProg), + close: () => { try { c.close(); } catch (_) {} log.info('[FTP] Extra client closed'); }, + get closed() { return c.closed; }, + }; +} + +// ── Dedicated single-file download (for large media) ───────────────────────── +// Opens a throwaway FTP connection with a generous idle timeout (5 min) so large +// video transfers aren't killed by the shared 30-second timeout. Does NOT hold +// the singleton lock — other FTP operations can proceed in parallel. +// +// onProgress receives { bytes, bytesOverall, total } where `total` is the remote +// file size (0 if unavailable). +async function downloadFileFresh(remotePath, localPath, onProgress) { + if (!_lastCreds) throw new Error('Not connected to FTP. Connect to your PS5 first.'); + + const c = new ftp.Client(300_000); // 5-minute idle timeout + c.ftp.verbose = false; + + try { + await c.access({ + host: _lastCreds.host, + port: parseInt(_lastCreds.port) || 21, + user: _lastCreds.user || 'anonymous', + password: _lastCreds.password || '', + secure: false, + }); + + // Grab remote file size so callers can show a real percentage + let total = 0; + try { total = await c.size(remotePath); } catch (_) {} + + c.trackProgress(info => { + if (onProgress) onProgress({ bytes: info.bytes, bytesOverall: info.bytesOverall, total }); + }); + + try { + await c.downloadTo(localPath, remotePath); + } finally { + c.trackProgress(); + } + } finally { + try { c.close(); } catch (_) {} + } +} + +module.exports = { connect, disconnect, isConnected, hasCredentials, scanGames, listDir, listDirectoryWithCwd, removeDir, downloadFile, downloadFileFresh, downloadPartialFile, uploadFile, uploadDirectory, downloadDirectory, listRemote, deleteRemote, renameRemote, createClient, createRawClient }; diff --git a/src/github.js b/src/github.js new file mode 100644 index 0000000..bebf213 --- /dev/null +++ b/src/github.js @@ -0,0 +1,307 @@ +'use strict'; + +const https = require('https'); +const dns = require('dns'); +const net = require('net'); +const fs = require('fs'); +const path = require('path'); +const log = require('electron-log'); + +let githubApiRateLimitResetAt = 0; + +function githubRateLimitError(resetAt = githubApiRateLimitResetAt) { + const effectiveReset = Number(resetAt) > Date.now() ? Number(resetAt) : Date.now() + 60_000; + const error = new Error(`GitHub API-limiet bereikt; probeer opnieuw na ${new Date(effectiveReset).toLocaleString('nl-BE')}.`); + error.code = 'GITHUB_RATE_LIMIT'; + error.retryAt = effectiveReset; + return error; +} + +function githubRateLimitState() { + return { limited: githubApiRateLimitResetAt > Date.now(), resetAt: githubApiRateLimitResetAt || null }; +} + +// ── Low-level HTTP helper ────────────────────────────────────────────────────── +// Follows redirects, returns { status, body } as a string buffer. + +function isPrivateAddress(address) { + if (!net.isIP(address)) return true; + const value = address.toLowerCase(); + if (value === '::1' || value === '::' || value.startsWith('fe80:') || value.startsWith('fc') || value.startsWith('fd')) return true; + const mapped = value.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/)?.[1]; + const ipv4 = mapped || (net.isIPv4(value) ? value : ''); + if (!ipv4) return false; + const parts = ipv4.split('.').map(Number); + return parts[0] === 10 || parts[0] === 127 || parts[0] === 0 || + (parts[0] === 169 && parts[1] === 254) || + (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || + (parts[0] === 192 && parts[1] === 168) || + (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127) || + parts[0] >= 224; +} + +function safeLookup(hostname, options, callback) { + dns.lookup(hostname, { all: true, verbatim: true }, (error, addresses) => { + if (error) return callback(error); + const publicAddresses = addresses.filter(item => !isPrivateAddress(item.address)); + if (!publicAddresses.length || publicAddresses.length !== addresses.length) return callback(new Error('De downloadbron verwijst naar een intern of gereserveerd netwerkadres.')); + if (options?.all) callback(null, publicAddresses); + else callback(null, publicAddresses[0].address, publicAddresses[0].family); + }); +} + +function validatedHttpsUrl(value, base) { + const parsed = new URL(value, base); + if (parsed.protocol !== 'https:' || parsed.username || parsed.password) throw new Error('Alleen openbare HTTPS-bronnen zijn toegestaan.'); + if (parsed.hostname === 'localhost' || (net.isIP(parsed.hostname) && isPrivateAddress(parsed.hostname))) throw new Error('Interne downloadadressen zijn niet toegestaan.'); + return parsed; +} + +function httpGet(url, extraHeaders = {}, depth = 0, maxBytes = 4 * 1024 * 1024) { + return new Promise((resolve, reject) => { + const parsed = validatedHttpsUrl(url); + + const options = { + hostname: parsed.hostname, + port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), + path: parsed.pathname + parsed.search, + method: 'GET', + headers: { + 'User-Agent': 'Chimera/1.0 (private PS5 library manager)', + 'Accept': 'application/vnd.github+json', + ...extraHeaders, + }, + lookup: safeLookup, + }; + + const req = https.request(options, res => { + // Follow redirects (GitHub asset downloads redirect through CDN) + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + res.resume(); // drain the redirect body so the socket is freed + if (depth >= 5) { reject(new Error('Too many redirects')); return; } + return resolve(httpGet(validatedHttpsUrl(res.headers.location, parsed).toString(), extraHeaders, depth + 1, maxBytes)); + } + + const chunks = []; + let received = 0; + res.on('data', chunk => { + received += chunk.length; + if (received > maxBytes) res.destroy(new Error('De externe respons is groter dan toegestaan.')); + else chunks.push(chunk); + }); + res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString('utf8'), headers: res.headers })); + res.on('error', err => reject(err)); + }); + + req.on('error', reject); + req.setTimeout(30000, () => req.destroy(new Error('GitHub request timed out'))); + req.end(); + }); +} + +// ── URL parsing ──────────────────────────────────────────────────────────────── + +function parseGitHubUrl(url) { + const m = String(url).match(/github\.com\/([^/\s]+)\/([^/?\s#]+)/); + if (!m) throw new Error(`Not a valid GitHub repository URL: ${url}`); + return { owner: m[1], repo: m[2].replace(/\.git$/i, '') }; +} + +// ── GitHub Releases API ──────────────────────────────────────────────────────── + +// Fetches an arbitrary page URL and scrapes it for .bin / .js / .elf download links. +// Used when a source URL is a GitHub Pages site or any other hosted release page. +async function fetchPagePayloads(url) { + validatedHttpsUrl(url); + log.info(`[GitHub] Scraping page for payload links: ${url}`); + const { status, body } = await httpGet(url, { Accept: 'text/html,*/*' }, 0, 2 * 1024 * 1024); + if (status !== 200) throw new Error(`HTTP ${status} fetching page: ${url}`); + + // Collect all href values ending in .bin / .js / .elf (case-insensitive) + const linkRe = /href=["']([^"']*\.(?:bin|js|elf)(?:\?[^"']*)?)["']/gi; + const assets = []; + const seen = new Set(); + let m; + while ((m = linkRe.exec(body)) !== null) { + const href = m[1]; + const absUrl = validatedHttpsUrl(href, url).href; + const name = decodeURIComponent(absUrl.split('/').pop().split('?')[0]); + if (!seen.has(name)) { + seen.add(name); + const nearby = body.slice(m.index, m.index + 1200); + const digest = nearby.match(/sha256:([a-f0-9]{64})/i)?.[1]?.toLowerCase() || null; + assets.push({ name, url: absUrl, size: 0, digest }); + } + } + + // Best-effort version: look for a semver-like string in the page title or an

    /release heading + const titleM = body.match(/]*>([^<]{1,120})<\/title>/i); + const releaseTag = body.match(/\/releases\/tag\/([^"'/?#]+)/i)?.[1]; + const verM = body.match(/v?(\d+\.\d+[.\w-]*)/); + const tag = releaseTag ? decodeURIComponent(releaseTag) : (verM ? `v${verM[1]}` : (titleM ? titleM[1].trim().slice(0, 60) : 'latest')); + + log.info(`[GitHub] Page scrape found ${assets.length} payload(s), tag: ${tag}`); + return { tag, name: tag, date: null, url, assets }; +} + +async function fetchLatestRelease(githubUrl) { + // Non-GitHub URLs (e.g. github.io, any hosted page) — scrape for payload links + if (!/github\.com\//.test(githubUrl)) { + return fetchPagePayloads(githubUrl); + } + + const { owner, repo } = parseGitHubUrl(githubUrl); + const apiUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`; + + if (githubApiRateLimitResetAt > Date.now()) throw githubRateLimitError(); + if (githubApiRateLimitResetAt) githubApiRateLimitResetAt = 0; + log.info(`[GitHub] Fetching latest release for ${owner}/${repo}`); + const { status, body, headers } = await httpGet(apiUrl); + + if (status === 404) { + const releasePage = `${String(githubUrl).replace(/\/+$/, '').replace(/\/releases(?:\/latest)?$/i, '')}/releases`; + const scraped = await fetchPagePayloads(releasePage); + if (!scraped.assets.length) throw new Error(`No payload release assets found for ${owner}/${repo}`); + return scraped; + } + if (status === 403 && headers?.['x-ratelimit-remaining'] === '0') { + const reset = Number(headers['x-ratelimit-reset'] || 0) * 1000; + githubApiRateLimitResetAt = reset > Date.now() ? reset : Date.now() + 60_000; + throw githubRateLimitError(); + } + if (status !== 200) throw new Error(`GitHub API returned HTTP ${status} for ${owner}/${repo}`); + + let release; + try { release = JSON.parse(body); } catch (_) { throw new Error('Invalid JSON from GitHub API'); } + + return { + tag: release.tag_name || '', + name: release.name || release.tag_name || '', + date: release.published_at || null, + url: release.html_url || `https://github.com/${owner}/${repo}/releases`, + assets: (release.assets || []).map(a => ({ + name: a.name, + url: a.browser_download_url, + size: a.size || 0, + digest: /^sha256:[a-f0-9]{64}$/i.test(a.digest || '') ? a.digest.slice(7).toLowerCase() : null, + contentType: a.content_type || null, + downloadCount: a.download_count || 0, + createdAt: a.created_at || release.published_at || null, + })), + }; +} + +// ── Checksum file handling ───────────────────────────────────────────────────── +// Looks for a checksum file asset (sha256/checksums/sums/sha2 in name). +// Parses standard sha256sum output: "<64hexchars> " or "<64hexchars> *" +// Returns Map — empty Map if no checksum asset found. + +async function fetchChecksums(assets) { + const checksumAsset = assets.find(a => /sha256|checksums|sha2|sums/i.test(a.name)); + if (!checksumAsset) { + log.info('[GitHub] No checksums file found in release assets'); + return new Map(); + } + + log.info(`[GitHub] Downloading checksums file: ${checksumAsset.name}`); + const { status, body } = await httpGet(checksumAsset.url, {}, 0, 1024 * 1024); + if (status !== 200) { + log.warn(`[GitHub] Checksums download returned HTTP ${status}`); + return new Map(); + } + + const hashMap = new Map(); + for (const line of body.split(/\r?\n/)) { + // Match: 64-char hex, whitespace (possibly with *), then filename + const m = line.trim().match(/^([a-f0-9]{64})\s+\*?(.+)$/i); + if (m) { + hashMap.set(m[2].trim(), m[1].toLowerCase()); + } + } + log.info(`[GitHub] Parsed ${hashMap.size} checksum(s) from ${checksumAsset.name}`); + return hashMap; +} + +// ── Asset downloader ────────────────────────────────────────────────────────── +// Downloads a GitHub release asset to localPath, streaming with progress. +// onProgress({ percent, transferred, total, speedBps }) + +function downloadAsset(assetUrl, localPath, onProgress, maxBytes = 64 * 1024 * 1024) { + return new Promise((resolve, reject) => { + fs.mkdirSync(path.dirname(localPath), { recursive: true }); + + function doDownload(url, depth = 0) { + const parsed = validatedHttpsUrl(url); + + const options = { + hostname: parsed.hostname, + port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), + path: parsed.pathname + parsed.search, + method: 'GET', + headers: { 'User-Agent': 'Chimera/1.0 (private PS5 library manager)' }, + lookup: safeLookup, + }; + + const req = https.request(options, res => { + // Follow redirects (GitHub CDN) + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + res.resume(); // drain the redirect body so the socket is freed + if (depth >= 5) { reject(new Error('Too many redirects')); return; } + return doDownload(validatedHttpsUrl(res.headers.location, parsed).toString(), depth + 1); + } + if (res.statusCode !== 200) { + res.resume(); + return reject(new Error(`Download failed: HTTP ${res.statusCode} from ${url}`)); + } + + const total = parseInt(res.headers['content-length'] || '0') || 0; + if (total > maxBytes) { + res.resume(); + return reject(new Error('De payload is groter dan de limiet van 64 MB.')); + } + let done = 0; + let lastTime = Date.now(); + let lastBytes = 0; + + const out = fs.createWriteStream(localPath); + + res.on('data', chunk => { + done += chunk.length; + if (done > maxBytes) { + res.destroy(new Error('De payload is groter dan de limiet van 64 MB.')); + return; + } + const now = Date.now(); + const dt = (now - lastTime) / 1000; + if (dt >= 0.25) { + const speedBps = (done - lastBytes) / dt; + onProgress?.({ + percent: total > 0 ? Math.round((done / total) * 100) : 0, + transferred: done, + total, + speedBps, + }); + lastTime = now; + lastBytes = done; + } + }); + + res.on('error', err => { out.destroy(); fs.rmSync(localPath, { force: true }); reject(err); }); + out.on('error', err => { fs.rmSync(localPath, { force: true }); reject(err); }); + out.on('finish', () => { + onProgress?.({ percent: 100, transferred: done, total: done, speedBps: 0 }); + resolve(localPath); + }); + + res.pipe(out); + }); + req.on('error', reject); + req.setTimeout(60000, () => req.destroy(new Error('Asset download timed out'))); + req.end(); + } + + doDownload(assetUrl); + }); +} + +module.exports = { parseGitHubUrl, fetchLatestRelease, fetchPagePayloads, fetchChecksums, downloadAsset, isPrivateAddress, validatedHttpsUrl, githubRateLimitState }; diff --git a/src/hashing.js b/src/hashing.js new file mode 100644 index 0000000..1d88432 --- /dev/null +++ b/src/hashing.js @@ -0,0 +1,32 @@ +'use strict'; + +const fs = require('fs'); +const crypto = require('crypto'); + +/** + * Compute SHA-256 of a file, streaming it so large PKGs don't eat RAM. + * onProgress({ bytesRead, total, percent }) is called each chunk. + */ +function hashFile(filePath, onProgress) { + return new Promise((resolve, reject) => { + let stat; + try { stat = fs.statSync(filePath); } catch (e) { return reject(e); } + + const total = stat.size; + const hash = crypto.createHash('sha256'); + const stream = fs.createReadStream(filePath, { highWaterMark: 4 * 1024 * 1024 }); // 4 MB chunks + + let bytesRead = 0; + + stream.on('data', chunk => { + hash.update(chunk); + bytesRead += chunk.length; + onProgress?.({ bytesRead, total, percent: Math.round((bytesRead / total) * 100) }); + }); + + stream.on('end', () => resolve(hash.digest('hex'))); + stream.on('error', err => reject(err)); + }); +} + +module.exports = { hashFile }; diff --git a/src/ipc/backpork-gen.js b/src/ipc/backpork-gen.js new file mode 100644 index 0000000..0fcb4b0 --- /dev/null +++ b/src/ipc/backpork-gen.js @@ -0,0 +1,407 @@ +'use strict'; + +const { Worker } = require('worker_threads'); +const { buildSdkVersionPairs } = require('../backpork-gen/sdk-patcher'); +const WORKER_PATH = require('path').join(__dirname, '../backpork-gen/processor-worker.js'); +const { + scanFakelibBase, + ensurePupUnpacker, + extractPupToDir, + MANUAL_DL_URL, +} = require('../backpork-gen/fakelib-manager'); + +module.exports = function register(ipcMain, { win, db, log, path, fs, dialog, app, store }) { + let _cancelFlag = false; + let _activeWorker = null; + + // Run processGame in a dedicated worker thread so the main process stays free. + function runGameInWorker({ sourceDir, outputDir, sdkPair, fakelibDir, onProgress }) { + return new Promise((resolve, reject) => { + const worker = new Worker(WORKER_PATH, { + workerData: { sourceDir, outputDir, sdkPair, fakelibDir }, + }); + _activeWorker = worker; + + // Track settlement so we never leave the promise hanging. + let settled = false; + const settle = (fn, val) => { if (!settled) { settled = true; fn(val); } }; + + worker.on('message', msg => { + if (msg.type === 'progress') onProgress(msg.data); + else if (msg.type === 'done') { _activeWorker = null; settle(resolve, msg.result); } + else if (msg.type === 'error') { _activeWorker = null; settle(reject, new Error(msg.message)); } + }); + + worker.on('error', err => { _activeWorker = null; settle(reject, err); }); + // Always settle on exit — if already resolved this is a safe no-op via the settled flag. + worker.on('exit', code => { + if (_activeWorker === worker) _activeWorker = null; + settle(reject, new Error(code !== 0 + ? `Worker exited with code ${code}` + : 'Worker exited without completing')); + }); + }); + } + + // Helper: get the current merged pairs (built-in + any user-defined custom pairs) + function getActiveSdkPairs() { + const custom = store?.get('customSdkPairs', {}) || {}; + return buildSdkVersionPairs(custom); + } + + ipcMain.handle('bpgen:sdk-pairs', () => { + return Object.entries(getActiveSdkPairs()).map(([k, [ps5, ps4]]) => ({ + pair: Number(k), + ps5Ver: `0x${ps5.toString(16).padStart(8, '0').toUpperCase()}`, + ps4Ver: `0x${ps4.toString(16).padStart(8, '0').toUpperCase()}`, + })); + }); + + ipcMain.handle('bpgen:custom-sdk-pairs-get', () => store?.get('customSdkPairs', {}) || {}); + ipcMain.handle('bpgen:custom-sdk-pairs-set', (_e, pairs) => { + store?.set('customSdkPairs', pairs || {}); + return { ok: true }; + }); + + ipcMain.handle('bpgen:list-missing', (_e, { firmwareLabel = '' } = {}) => { + const all = db.listGamesWithLocalFolder(); + const withBP = db.gameIdsWithBackporkForFw(firmwareLabel || null); + return all.filter(g => !withBP.has(g.game_id)); + }); + + ipcMain.handle('bpgen:list-all-with-local', () => db.listGamesWithLocalFolder()); + + ipcMain.handle('bpgen:pick-folder', async () => { + const { canceled, filePaths } = await dialog.showOpenDialog(win, { + properties: ['openDirectory'], + title: 'Select Folder', + }); + return canceled ? null : filePaths[0]; + }); + + ipcMain.handle('bpgen:cancel', () => { + _cancelFlag = true; + _activeWorker?.postMessage('cancel'); + }); + + ipcMain.handle('bpgen:fakelib-scan', () => { + const baseDir = store?.get('bpgenFakelibBaseDir', store?.get('bpgen.fakelibBaseDir', '')) || ''; + if (!baseDir) return { baseDir: '', pairs: [] }; + return { baseDir, pairs: scanFakelibBase(baseDir) }; + }); + + ipcMain.handle('bpgen:fakelib-set-basedir', async (_e, { dir } = {}) => { + if (dir) { + store?.set('bpgenFakelibBaseDir', dir); + store?.set('bpgen.fakelibBaseDir', dir); + return { baseDir: dir, pairs: scanFakelibBase(dir) }; + } + const { canceled, filePaths } = await dialog.showOpenDialog(win, { + properties: ['openDirectory'], + title: 'Select Fakelib Base Directory', + }); + if (canceled) return null; + const baseDir = filePaths[0]; + store?.set('bpgenFakelibBaseDir', baseDir); + store?.set('bpgen.fakelibBaseDir', baseDir); + return { baseDir, pairs: scanFakelibBase(baseDir) }; + }); + + ipcMain.handle('bpgen:fakelib-pick-pup', async () => { + const { canceled, filePaths } = await dialog.showOpenDialog(win, { + title: 'Select Decrypted PS5 Firmware PUP', + filters: [ + { name: 'Decrypted PUP', extensions: ['dec'] }, + { name: 'All Files', extensions: ['*'] }, + ], + properties: ['openFile'], + }); + return canceled ? null : filePaths[0]; + }); + + ipcMain.handle('bpgen:fakelib-extract', async (_e, opts) => { + const { pupPath, sdkPair, baseDir: optBaseDir } = opts || {}; + if (!pupPath || !sdkPair) throw new Error('pupPath and sdkPair are required'); + + const baseDir = optBaseDir || store?.get('bpgenFakelibBaseDir', store?.get('bpgen.fakelibBaseDir', '')) || ''; + if (!baseDir) throw new Error('Set a Fakelib Base Directory first'); + + const targetDir = path.join(baseDir, String(sdkPair)); + const toolsDir = app ? path.join(app.getPath('userData'), 'tools') : path.join(baseDir, '.tools'); + const emitFL = (type, payload) => win?.webContents.send('bpgen:fakelib-progress', { type, ...payload }); + + emitFL('start', { sdkPair }); + log.info(`[FakelibMgr] Extracting PUP for SDK pair ${sdkPair} -> ${targetDir}`); + + try { + let toolPath; + try { + toolPath = await ensurePupUnpacker(toolsDir, ({ stage, pct, msg }) => + emitFL('tool-progress', { stage, pct, msg }) + ); + } catch (e) { + if (e.manualRequired) { + emitFL('tool-manual', { msg: e.message, url: MANUAL_DL_URL }); + throw e; + } + throw e; + } + + const { libCount } = await extractPupToDir({ + pupPath, + targetDir, + toolPath, + isCancelled: () => false, + onProgress: ({ stage, pct, msg }) => emitFL('extract-progress', { stage, pct, msg }), + }); + + log.info(`[FakelibMgr] Done - ${libCount} libraries extracted for pair ${sdkPair}`); + emitFL('done', { sdkPair, libCount, targetDir }); + return { libCount, targetDir }; + } catch (err) { + log.error(`[FakelibMgr] Extraction failed: ${err.message}`); + emitFL('error', { msg: err.message }); + throw err; + } + }); + + ipcMain.handle('bpgen:run', async (_e, opts) => { + const { + gameIds = [], + sdkPair, + outputDir, + firmwareLabel, + fakelibDir: fakelibDirOpt = null, + filterFw = '', + overwrite = false, + } = opts || {}; + + if (!sdkPair || !outputDir) { + throw new Error('sdkPair and outputDir are required'); + } + + const activePairs = getActiveSdkPairs(); + const targetPairs = sdkPair === 'all' + ? Object.keys(activePairs).map(Number).sort((a, b) => a - b) + : [Number(sdkPair)]; + if (!targetPairs.length || targetPairs.some(pair => !activePairs[pair])) { + throw new Error(`Invalid or unconfigured SDK pair: ${sdkPair}. Add PS5/PS4 version values in Settings → Custom SDK Pairs.`); + } + if (sdkPair !== 'all' && !firmwareLabel) { + throw new Error('firmwareLabel is required for a single SDK target'); + } + + _cancelFlag = false; + const emit = (type, payload) => win?.webContents.send('bpgen:progress', { type, ...payload }); + + const totalInDb = db.getStats().totalGames; + let games = db.listGamesWithLocalFolder(); + const noLocalFolder = totalInDb - games.length; // games in DB with no local folder recorded + if (gameIds.length > 0) { + const set = new Set(gameIds); + games = games.filter(g => set.has(g.game_id)); + } else if (filterFw) { + const withBP = db.gameIdsWithBackporkForFw(filterFw); + games = games.filter(g => !withBP.has(g.game_id)); + } else { + // For single-pair runs use the specific firmware label so games that + // already have a backpork for a *different* firmware are not skipped. + // For all-pairs mode firmwareLabel is '' here; each pair re-filters below. + const withBP = db.gameIdsWithBackporkForFw(firmwareLabel || null); + games = games.filter(g => !withBP.has(g.game_id)); + } + + if (games.length === 0) { + emit('done', { total: 0, ok: 0, failed: 0, skipped: 0 }); + return { total: 0, ok: 0, failed: 0 }; + } + + const pairPlans = targetPairs.map(targetPair => { + const targetFirmwareLabel = sdkPair === 'all' ? `${targetPair}.xx` : firmwareLabel; + const pairGames = sdkPair === 'all' + ? games.filter(g => !db.gameIdsWithBackporkForFw(targetFirmwareLabel).has(g.game_id)) + : games; + return { targetPair, targetFirmwareLabel, pairGames }; + }); + + const totalRuns = pairPlans.reduce((sum, plan) => sum + plan.pairGames.length, 0); + emit('start', { total: totalRuns, games: games.length, pairs: targetPairs.length, noLocalFolder }); + emit('run-config', { + sdkPair, + firmwareLabel, + outputDir, + fakelibDir: fakelibDirOpt || '', + overwrite, + selectedGames: games.length, + targetPairs, + }); + log.info(`[BackporkGen] Starting: ${games.length} game(s), target=${sdkPair}`); + + let totalOk = 0; + let totalFailed = 0; + let totalSkipped = 0; + let processedCount = 0; + + for (const plan of pairPlans) { + const { targetPair, targetFirmwareLabel, pairGames } = plan; + let fakelibDir = fakelibDirOpt || null; + if (!fakelibDir) { + const baseDir = store?.get('bpgenFakelibBaseDir', store?.get('bpgen.fakelibBaseDir', '')) || ''; + if (baseDir) { + const pairStatus = scanFakelibBase(baseDir).find(p => p.pair === targetPair); + if (pairStatus?.hasLibs) { + fakelibDir = pairStatus.dir; + log.info(`[BackporkGen] Auto-resolved fakelib for pair ${targetPair}: ${fakelibDir}`); + } + } + } + + const pairIndex = targetPairs.indexOf(targetPair) + 1; // 1-based + const totalPairs = targetPairs.length; + let pairGameIndex = 0; + + // Always emit pair-start so the counter advances even when all games are already done + emit('pair-start', { + pairIndex, + totalPairs, + sdkPair: targetPair, + firmwareLabel: targetFirmwareLabel, + gamesInPair: pairGames.length, + }); + + for (const game of pairGames) { + if (_cancelFlag) { + emit('cancelled', { processed: processedCount }); + break; + } + + processedCount++; + pairGameIndex++; + const sourceDir = game.backup_path; + const gameOutDir = path.join(outputDir, targetFirmwareLabel, game.game_id); + + emit('game-start', { + gameId: game.game_id, + title: game.prospero_name || game.title || game.game_id, + index: processedCount, + total: totalRuns, + // Per-pair counters so the renderer can display [X/58 · pair Y/10] + pairGameIndex, + pairTotal: pairGames.length, + pairIndex, + totalPairs, + sdkPair: targetPair, + firmwareLabel: targetFirmwareLabel, + }); + + if (!overwrite && fs.existsSync(gameOutDir)) { + try { + if (fs.readdirSync(gameOutDir).length > 0) { + emit('game-skip', { + gameId: game.game_id, + reason: 'output already exists', + sdkPair: targetPair, + firmwareLabel: targetFirmwareLabel, + }); + totalSkipped++; + continue; + } + } catch {} + } + + if (!fs.existsSync(sourceDir)) { + emit('game-error', { + gameId: game.game_id, + msg: 'Source folder not found on disk', + sdkPair: targetPair, + firmwareLabel: targetFirmwareLabel, + }); + totalFailed++; + continue; + } + + try { + const result = await runGameInWorker({ + sourceDir, + outputDir: gameOutDir, + sdkPair: targetPair, + fakelibDir: fakelibDir || null, + onProgress: progress => emit('file-progress', { + gameId: game.game_id, + sdkPair: targetPair, + firmwareLabel: targetFirmwareLabel, + ...progress, + }), + }); + + totalOk += result.ok; + totalFailed += result.failed; + totalSkipped += result.skipped; + + try { + const fwFolderPath = path.join(outputDir, targetFirmwareLabel); + const existing = db.listBackporkFolders().find( + f => path.normalize(f.path).toLowerCase() === path.normalize(fwFolderPath).toLowerCase() + ); + if (!existing) db.addBackporkFolder(targetFirmwareLabel, fwFolderPath); + + db.upsertGameMinimal(game.game_id); + db.setGameFirmwareLabel(game.game_id, targetFirmwareLabel); + + const size = await getDirSize(gameOutDir); + db.upsertBackup({ + game_id: game.game_id, + backup_path: gameOutDir, + size, + backup_type: 'folder', + source: 'backpork', + }); + } catch (dbErr) { + log.warn(`[BackporkGen] DB register failed for ${game.game_id}: ${dbErr.message}`); + } + + emit('game-done', { + gameId: game.game_id, + ok: result.ok, + failed: result.failed, + sdkPair: targetPair, + firmwareLabel: targetFirmwareLabel, + }); + } catch (err) { + log.error(`[BackporkGen] Failed ${game.game_id}: ${err.message}`); + // Remove partial output so a re-run doesn't see a non-empty dir and + // wrongly skip this failed game as "output already exists". + try { fs.rmSync(gameOutDir, { recursive: true, force: true }); } catch {} + emit('game-error', { + gameId: game.game_id, + msg: err.message, + sdkPair: targetPair, + firmwareLabel: targetFirmwareLabel, + }); + totalFailed++; + } + } + + if (_cancelFlag) break; + } + + emit('done', { total: totalRuns, ok: totalOk, failed: totalFailed, skipped: totalSkipped }); + log.info(`[BackporkGen] Done - ok:${totalOk} failed:${totalFailed} skipped:${totalSkipped}`); + return { total: totalRuns, ok: totalOk, failed: totalFailed }; + }); + + async function getDirSize(dir) { + let total = 0; + try { + const entries = await fs.promises.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) total += await getDirSize(full); + else { + try { total += (await fs.promises.stat(full)).size; } catch {} + } + } + } catch {} + return total; + } +}; diff --git a/src/ipc/bdjb-generator.js b/src/ipc/bdjb-generator.js new file mode 100644 index 0000000..f4843f7 --- /dev/null +++ b/src/ipc/bdjb-generator.js @@ -0,0 +1,60 @@ +'use strict'; + +const path = require('path'); +const os = require('os'); +const fs = require('fs'); +const { spawn } = require('child_process'); +const { prepareBdjbWorkspace } = require('../bdjb-generator'); + +function run(command, args, options, onLine) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { ...options, shell: false }); + let output = ''; + const consume = (chunk) => { + const text = chunk.toString(); output += text; + text.split(/\r?\n/).filter(Boolean).forEach(onLine); + }; + child.stdout.on('data', consume); child.stderr.on('data', consume); + child.once('error', reject); + child.once('close', (code) => code === 0 ? resolve(output) : reject(new Error(`${command} exited with code ${code}.\n${output.slice(-1600)}`))); + }); +} + +module.exports = function registerBdjbGenerator(ipcMain, { app, dialog, log, win }) { + const templateDir = path.join(__dirname, '..', '..', 'build', 'bdjb-template'); + const emit = (event, payload) => win?.webContents.send(event, payload); + + ipcMain.handle('bdjbgen:select-payloads', async () => { + const result = await dialog.showOpenDialog({ title: 'Select BDJB autoloader payloads', properties: ['openFile', 'multiSelections'], filters: [{ name: 'ELF or BIN payloads', extensions: ['elf', 'bin'] }] }); + return result.canceled ? [] : result.filePaths.map((filePath) => ({ name: path.basename(filePath), path: filePath })); + }); + + ipcMain.handle('bdjbgen:build', async (_event, { entries, payloads, discTitle }) => { + const save = await dialog.showSaveDialog({ title: 'Save BDJB ISO', defaultPath: 'chimera-bdjb-autoloader.iso', filters: [{ name: 'ISO image', extensions: ['iso'] }] }); + if (save.canceled || !save.filePath) return { canceled: true }; + const outputIso = save.filePath.toLowerCase().endsWith('.iso') ? save.filePath : `${save.filePath}.iso`; + const root = await fs.promises.mkdtemp(path.join(app.getPath('temp') || os.tmpdir(), 'porkfolio-bdjb-')); + const workspaceDir = path.join(root, 'Cyberpunk'); + const progress = (stage, message, percent) => emit('bdjbgen:progress', { stage, message, percent }); + try { + progress('prepare', 'Preparing the bundled Cyberpunk BDJB template.', 10); + const prepared = await prepareBdjbWorkspace({ templateDir, workspaceDir, payloads, entries, discTitle, log: (message) => progress('prepare', message, 20) }); + progress('dependencies', 'Checking BDJ SDK and Java 8 build prerequisites.', 30); + const sdk = process.env.BDJ_SDK || '/opt/bdj-sdk'; + if (!fs.existsSync(path.join(sdk, 'host', 'bin', 'makefs'))) throw new Error(`BDJ SDK is not configured at ${sdk}. Set BDJ_SDK to a complete john-tornblom/bdj-sdk install before building.`); + const javaHome = process.env.JAVA8_HOME || path.join(sdk, 'host', 'jdk8'); + if (!fs.existsSync(path.join(javaHome, 'bin', 'javac'))) throw new Error(`Java 8 is not configured at ${javaHome}. Set JAVA8_HOME to a Java 8 JDK before building.`); + progress('compile', 'Compiling the BD-J payload and producing the ISO.', 45); + await run('make', ['clean'], { cwd: workspaceDir, env: { ...process.env, BDJSDK_HOME: sdk, JAVA8_HOME: javaHome } }, (line) => progress('compile', line, 65)); + await run('make', ['all'], { cwd: workspaceDir, env: { ...process.env, BDJSDK_HOME: sdk, JAVA8_HOME: javaHome } }, (line) => progress('compile', line, 80)); + const generated = path.join(workspaceDir, 'cyberbdjb-autoloader.iso'); + if (!fs.existsSync(generated) || (await fs.promises.stat(generated)).size < 1024) throw new Error('BDJB build did not produce a usable ISO.'); + progress('finalize', 'Copying and validating ISO output.', 90); + await fs.promises.copyFile(generated, outputIso); + const size = (await fs.promises.stat(outputIso)).size; + log.info(`[BDJB Genny] Built ${outputIso} (${size} bytes), ${prepared.payloadCount} payload(s).`); + progress('done', `ISO validated: ${path.basename(outputIso)} (${size} bytes).`, 100); + return { ok: true, outputIso, bytes: size, payloadCount: prepared.payloadCount }; + } finally { await fs.promises.rm(root, { recursive: true, force: true }).catch(() => {}); } + }); +}; diff --git a/src/ipc/conversion.js b/src/ipc/conversion.js new file mode 100644 index 0000000..a4916ff --- /dev/null +++ b/src/ipc/conversion.js @@ -0,0 +1,808 @@ +'use strict'; +module.exports = function register(ipcMain, { win, store, log, path, fs, os, spawn, ps5Notify, transferMgr, dialog, ftp }) { +const { createExfatImage } = require('../exfat/native-exfat'); +const { buildPfs } = require('../pfs/native-pfs'); +const { getConversionCapabilities } = require('../platform-capabilities'); +const conversionCapabilities = getConversionCapabilities(); +// ── GAME CONVERSION ENGINE (FFPKG / ExFAT) ─── Windows-only ────────────────── +// ══════════════════════════════════════════════════════════════════════════════ + +// ── File utilities ──────────────────────────────────────────────────────────── + +function fmtBytes(n) { + if (n >= 1073741824) return `${(n / 1073741824).toFixed(2)} GB`; + if (n >= 1048576) return `${(n / 1048576).toFixed(1)} MB`; + if (n >= 1024) return `${(n / 1024).toFixed(0)} KB`; + return `${n} B`; +} + +async function convCollectFiles(dir, base = dir) { + const results = []; + const visited = new Set(); + const stack = [dir]; + + while (stack.length) { + const current = stack.pop(); + + // Resolve the real path to detect symlink / junction cycles + let realPath; + try { realPath = await fs.promises.realpath(current); } catch { realPath = current; } + if (visited.has(realPath)) continue; + visited.add(realPath); + + let entries; + try { entries = await fs.promises.readdir(current, { withFileTypes: true }); } + catch (err) { log.warn(`[Conv] Cannot read dir: ${current} — ${err.message}`); continue; } + + for (const e of entries) { + const full = path.join(current, e.name); + let isDir = e.isDirectory(); + if (!isDir && !e.isFile()) { + try { isDir = (await fs.promises.stat(full)).isDirectory(); } catch (_) { isDir = false; } + } + if (isDir) stack.push(full); + else results.push({ src: full, rel: path.relative(base, full) }); + } + } + return results; +} + +async function convCopyDirWithProgress(srcDir, destDir, { onProgress, getCancelled } = {}) { + const files = await convCollectFiles(srcDir); + let bytesDone = 0, bytesTotal = 0; + const sizes = await Promise.all(files.map(f => + fs.promises.stat(f.src).then(s => s.size).catch(() => 0) + )); + sizes.forEach(s => { bytesTotal += s; }); + + for (let i = 0; i < files.length; i++) { + if (getCancelled?.()) throw new Error('Cancelled'); + const { src, rel } = files[i]; + const dest = path.join(destDir, rel); + await fs.promises.mkdir(path.dirname(dest), { recursive: true }); + await new Promise((resolve, reject) => { + const rs = fs.createReadStream(src); + const ws = fs.createWriteStream(dest); + rs.on('data', chunk => { + bytesDone += chunk.length; + onProgress?.({ file: rel, index: i + 1, total: files.length, bytesDone, bytesTotal }); + if (getCancelled?.()) { rs.destroy(); ws.destroy(new Error('Cancelled')); } + }); + rs.on('error', reject); + ws.on('error', err => { rs.destroy(); reject(err); }); + ws.on('close', resolve); + rs.pipe(ws); + }); + } + return { files: files.length, bytes: bytesDone }; +} + +// Find the game's subfolder within a firmware folder path +function findGameSubfolder(folderPath, game_id) { + try { + const entries = fs.readdirSync(folderPath, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const m = entry.name.match(/([A-Z]{4}\d{5})/i); + if (m && m[1].toUpperCase() === game_id.toUpperCase()) return entry.name; + } + } catch (_) {} + return null; +} + +// ── UFS2Tool runner ─────────────────────────────────────────────────────────── + +const CONV_UFS2_PROGRESS_RE = /Adding files to image\.\.\.\s+(\d+)%\s+\((\d+)\/(\d+)\s+files,\s+([\d.]+ \S+)\/([\d.]+ \S+)\)/; + +function convParseUfs2Progress(text) { + for (const line of text.split(/[\r\n]/)) { + const m = CONV_UFS2_PROGRESS_RE.exec(line); + if (m) return { + toolPct: parseInt(m[1], 10), + filesNow: parseInt(m[2], 10), + filesTotal: parseInt(m[3], 10), + bytesNow: m[4], + bytesTotal: m[5], + }; + } + return null; +} + +// Best-effort kill of a spawned child and its descendants. On Windows the exfat +// path runs cmd.exe → powershell, so a plain proc.kill() would orphan the child; +// taskkill /T /F takes down the whole tree. +function convKillProc(proc) { + if (!proc || proc.killed) return; + try { + if (process.platform === 'win32') { + require('child_process').spawn('taskkill', ['/pid', String(proc.pid), '/T', '/F'], { stdio: 'ignore' }); + } else { + proc.kill('SIGKILL'); + } + } catch (_) { /* already gone */ } +} + +function convRunUfs2(args, { onData, onProgress, onProc } = {}) { + return new Promise((resolve, reject) => { + const toolPath = store.get('conv.ufs2ToolPath', ''); + if (!toolPath || !fs.existsSync(toolPath)) { + return reject(new Error( + toolPath + ? `UFS2Tool not found at: ${toolPath}` + : 'UFS2Tool.exe path not configured. Set it in Settings → Game Conversion.' + )); + } + const proc = require('child_process').spawn(toolPath, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + onProc?.(proc); + let done = false; + const timeoutMs = store.get('conv.toolTimeoutMs', 30 * 60 * 1000); + const timer = setTimeout(() => { + if (done) return; done = true; + convKillProc(proc); + reject(new Error(`UFS2Tool timed out after ${Math.round(timeoutMs / 60000)} min`)); + }, timeoutMs); + const out = []; + const handle = (d) => { + const s = d.toString(); + out.push(s); + onData?.(s); + const parsed = convParseUfs2Progress(s); + if (parsed) onProgress?.(parsed); + }; + proc.stdout.on('data', handle); + proc.stderr.on('data', handle); + proc.on('error', err => { if (done) return; done = true; clearTimeout(timer); reject(err); }); + proc.on('close', code => { + if (done) return; done = true; clearTimeout(timer); + const text = out.join(''); + if (code === 0) resolve(text); + else reject(new Error(`UFS2Tool exited ${code}: ${text.slice(0, 300)}`)); + }); + }); +} + +function convMakefPS5(inputDir, outputFile, opts = {}) { + return convRunUfs2([ + 'makefs', '-S', '4096', '-t', 'ffs', + '-o', 'version=2,minfree=0,softupdates=0,optimization=space', + outputFile, inputDir, + ], opts); +} + +function convNewfsPS5(inputDir, outputFile, opts = {}) { + return convRunUfs2(['newfs', '-D', inputDir, outputFile], opts); +} + +/** + * Find the directory that directly contains eboot.bin via BFS (up to 5 levels). + * Handles layouts: + * workerDir/eboot.bin + * workerDir/app0/eboot.bin + * workerDir/Image0/app0/eboot.bin + * workerDir/PPSA21837/Image0/app0/eboot.bin + * Returns the path that directly contains eboot.bin, or null if not found. + * + * Uses fs.statSync() fallback for junction points / symlinks so Windows reparse + * points (common in PS5 game backups) are followed when present in workerDir. + */ +function convFindGameRoot(dir) { + const MAX_DEPTH = 5; + // BFS queue entries: { absPath, depth } + const queue = [{ absPath: dir, depth: 0 }]; + let found = null; + + while (queue.length) { + const { absPath, depth } = queue.shift(); + + // Does this directory directly contain eboot.bin? + if (fs.existsSync(path.join(absPath, 'eboot.bin'))) { + if (found && found !== absPath) return null; // ambiguous — multiple candidates + found = absPath; + continue; // don't descend further from a match + } + + if (depth < MAX_DEPTH) { + try { + for (const e of fs.readdirSync(absPath, { withFileTypes: true })) { + let isDir = e.isDirectory(); // lstat-based — misses junction points / symlinks + if (!isDir && !e.isFile()) { + // Reparse point / junction / symlink — use stat() to follow it + try { isDir = fs.statSync(path.join(absPath, e.name)).isDirectory(); } catch (_) { isDir = false; } + } + if (isDir) queue.push({ absPath: path.join(absPath, e.name), depth: depth + 1 }); + } + } catch (_) {} + } + } + + return found; +} + +// Resolve the OSFMount executable: explicit setting, then legacy exfat-tools +// folder (older setups bundled osfmount.com there), then standard install dirs. +function resolveOsfmountPath() { + const legacyDir = store.get('conv.exfatToolPath', ''); + const candidates = [ + store.get('conv.osfmountPath', ''), + legacyDir ? path.join(legacyDir, 'osfmount.com') : '', + 'C:\\Program Files\\OSFMount\\osfmount.com', + 'C:\\Program Files (x86)\\OSFMount\\osfmount.com', + ].filter(Boolean); + for (const c of candidates) { if (fs.existsSync(c)) return c; } + return ''; +} + +// Native exFAT builder — no external make_image.bat / .ps1, only OSFMount. +// Ported from ps5-image-studio's create_exfat.py Windows path. +function convBuildExfatNative(outputFile, inputDir, job, { onData, onProc } = {}) { + const osfmountPath = resolveOsfmountPath(); + const totalRaw = { v: 0 }; + let copiedBytes = 0; + return createExfatImage({ + sourceDir: inputDir, + outputFile, + label: (job && job.game_id) ? job.game_id.slice(0, 15) : 'PS5exfat', + clusterSize: store.get('conv.exfatCluster', 0) || null, + osfmountPath, + onLog: (line) => onData?.(line.endsWith('\n') ? line : line + '\n'), + onProgress: (p) => { + if (!job) return; + if (p.phase) job.progress.phase = p.phase; + if (p.total) totalRaw.v = p.total; + if (p.bytes) copiedBytes += p.bytes; + // Copy phase spans 50→95% of the overall job. + if (totalRaw.v > 0 && copiedBytes > 0) { + job.progress.percent = 50 + Math.min(45, Math.round((copiedBytes / totalRaw.v) * 45)); + } + sendConvJobUpdate(job); + }, + isCancelled: () => !!(job && job._cancelled), + onProc: (proc) => { if (job) job._activeProc = proc; onProc?.(proc); }, + }); +} + +// Native FFPFSC (compressed PFS) builder — ports lazy_mkpfs, no external tool. +// ponytail: buildPfs is synchronous and runs on the main process; fine for small +// games but will block the UI on multi-GB dumps — move to a worker_thread if that +// becomes a problem (native-pfs.js is already worker-safe: pure fs/zlib). +function convBuildPfsNative(outputFile, inputDir, job, { onData } = {}) { + let totalRaw = 0, copied = 0; + return new Promise((resolve, reject) => { + setImmediate(() => { + try { + const res = buildPfs({ + sourceDir: inputDir, + outputPath: outputFile, + compress: true, + zlibLevel: store.get('conv.pfscZlibLevel', 6), + onLog: (line) => onData?.(line.endsWith('\n') ? line : line + '\n'), + onProgress: (p) => { + if (!job) return; + if (p.phase) job.progress.phase = p.phase; + if (p.total) totalRaw = p.total; + if (p.bytes) copied += p.bytes; + if (totalRaw > 0) job.progress.percent = 50 + Math.min(45, Math.round((copied / totalRaw) * 45)); + sendConvJobUpdate(job); + }, + isCancelled: () => !!(job && job._cancelled), + }); + resolve(res); + } catch (e) { reject(e); } + }); + }); +} + +// ── Conversion-specific PS5 notify helper ───────────────────────────────────── +function convPs5Notify(message, sub) { + if (!store.get('conv.psNotify.enabled', true)) return; + ps5Notify(message, sub || ''); +} + +// ── Conversion Job Queue ────────────────────────────────────────────────────── + +let convJobIdCounter = 1; +const convJobs = new Map(); +let convIsProcessing = false; +let convQueuePaused = true; + +function setConvQueuePaused(val) { + convQueuePaused = val; + win?.webContents.send('conv:queue:paused-change', convQueuePaused); +} + +function serializeConvJob(job) { + return { + id: job.id, + game_id: job.game_id, + game_name: job.game_name, + mode: job.mode, + firmware_label: job.firmware_label, + output_file: job.output_file, + ftpUpload: job.ftpUpload, + ftpRemotePath: job.ftpRemotePath, + deleteAfter: job.deleteAfter, + status: job.status, + progress: { ...job.progress }, + error: job.error, + createdAt: job.createdAt, + startedAt: job.startedAt, + completedAt: job.completedAt, + }; +} + +function sendConvJobUpdate(job) { + win?.webContents.send('conv:job-update', serializeConvJob(job)); +} + +function enqueueConvJob({ game_id, game_name, game_path, backpork_path, firmware_label, output_file, mode, ftpUpload, ftpRemotePath, deleteAfter }) { + const id = convJobIdCounter++; + const job = { + id, game_id, game_name: game_name || game_id, game_path, backpork_path, firmware_label, + output_file, mode: mode || 'ffpkg', + ftpUpload: !!ftpUpload, + ftpRemotePath: ftpRemotePath || '', + deleteAfter: !!deleteAfter, + status: 'queued', + progress: { phase: 'Queued', percent: 0, log: [] }, + error: null, + createdAt: Date.now(), + startedAt: null, + completedAt: null, + _cancelled: false, + }; + convJobs.set(id, job); + sendConvJobUpdate(job); + tickConvQueue(); + return id; +} + +function tickConvQueue() { + if (convQueuePaused) return; + if (convIsProcessing) return; + const next = [...convJobs.values()].find(j => j.status === 'queued'); + if (!next) { + setConvQueuePaused(true); + return; + } + convIsProcessing = true; + runConvJob(next).finally(() => { + convIsProcessing = false; + tickConvQueue(); + }); +} + +async function runConvJob(job) { + job.status = 'running'; + job.startedAt = Date.now(); + sendConvJobUpdate(job); + + const logLine = (msg) => { + job.progress.log.push(msg); + if (job.progress.log.length > 500) job.progress.log.shift(); + sendConvJobUpdate(job); + }; + + const workerBase = store.get('conv.tempDir', '') || os.tmpdir(); + const workerDir = path.join(workerBase, `pork_conv_${job.id}_${job.game_id}`); + + try { + // ── Phase 1: Resolve the game root, then copy it to temp worker ─────── + job.progress.phase = 'Locating game root…'; + job.progress.percent = 3; + sendConvJobUpdate(job); + + // Verify the source exists and is a directory, not a file backup (.ffpkg etc.) + let srcStat; + try { srcStat = fs.statSync(job.game_path); } catch (e) { + throw new Error(`Game source path not found: ${job.game_path}`); + } + if (!srcStat.isDirectory()) { + throw new Error( + `Game source is a file, not a folder: ${job.game_path}\n` + + `ExFAT/FFPKG conversion requires the raw game folder. Select a folder backup, not a .ffpkg or .exfat file.` + ); + } + + // For ExFAT, find the subfolder that directly contains eboot.bin NOW (in the source), + // then copy only from that folder — exactly what the standalone ufs2ElectronApp does + // (its game_path IS already the game root). This handles wrapper layouts like: + // game_path/eboot.bin → copy game_path + // game_path/PPSA01234/eboot.bin → copy game_path/PPSA01234 + // FFPKG mode: UFS2Tool understands the full tree, so always copy game_path directly. + const isExfat = job.mode === 'exfat'; + let copyFrom = job.game_path; + if (isExfat) { + const srcGameRoot = convFindGameRoot(job.game_path); + if (!srcGameRoot) { + throw new Error( + `ExFAT conversion failed: eboot.bin not found in the source game folder (searched up to 5 levels deep).\n` + + `Source: ${job.game_path}\n` + + `Ensure the backup folder contains a valid PS5 game structure with eboot.bin.` + ); + } + copyFrom = srcGameRoot; + if (copyFrom !== job.game_path) { + logLine(`[1/4] Game root detected at: …\\${path.relative(job.game_path, copyFrom)}`); + } + } + + job.progress.phase = 'Copying game — enumerating files…'; + job.progress.percent = 5; + sendConvJobUpdate(job); + logLine(`[1/4] Copying game from ${copyFrom}`); + logLine(` → ${workerDir}`); + if (store.get('conv.psNotify.onCopyStart', true)) + convPs5Notify(`${job.game_id} copying`, job.firmware_label ? `Patch: ${job.firmware_label}` : undefined); + + if (job._cancelled) throw new Error('Cancelled'); + fs.mkdirSync(workerDir, { recursive: true }); + + let _lastUiMs = 0; + const throttled = () => { + const now = Date.now(); + if (now - _lastUiMs >= 100) { _lastUiMs = now; sendConvJobUpdate(job); } + }; + + const cp1 = await convCopyDirWithProgress(copyFrom, workerDir, { + getCancelled: () => job._cancelled, + onProgress: ({ index, total, bytesDone, bytesTotal }) => { + const pct = 5 + Math.round((bytesTotal > 0 ? bytesDone / bytesTotal : index / total) * 30); + job.progress.phase = `Copying game — ${index}/${total} files (${fmtBytes(bytesDone)} / ${fmtBytes(bytesTotal)})`; + job.progress.percent = pct; + throttled(); + }, + }); + sendConvJobUpdate(job); + logLine(`[1/4] Game copy done — ${cp1.files} files, ${fmtBytes(cp1.bytes)}.`); + + // ── Phase 2: Overlay backpork (optional) ────────────────────────────── + if (job.backpork_path) { + // Resolve the actual game-patch directory. Older Porkfolio versions (pre-fix) + // passed bp.folder_path (the firmware folder, e.g. G:\Backporks\11.50.0) rather + // than bp.game_path (the game-specific subfolder, e.g. …\11.50.0\PPSA21837-app0). + // Detect that case by looking for canonical game markers (eboot.bin / sce_sys); + // if absent the path is probably a parent folder and we find the subfolder with + // findGameSubfolder() — the same resolution used by the PFS pork upload. + let patchDir = job.backpork_path; + const hasPatchMarker = fs.existsSync(path.join(patchDir, 'eboot.bin')) + || fs.existsSync(path.join(patchDir, 'sce_sys')); + if (!hasPatchMarker) { + const sub = findGameSubfolder(patchDir, job.game_id); + if (sub) { + patchDir = path.join(patchDir, sub); + logLine(`[2/4] Resolved game patch subfolder: …\\${path.relative(job.backpork_path, patchDir)}`); + } else { + logLine(`[2/4] Warning: no game-specific subfolder for ${job.game_id} found in ${patchDir} — skipping backpork overlay.`); + patchDir = null; + } + } + + if (patchDir && fs.existsSync(patchDir)) { + job.progress.phase = 'Applying backpork — enumerating files…'; + job.progress.percent = 35; + sendConvJobUpdate(job); + logLine(`[2/4] Applying backpork from ${patchDir}`); + if (job._cancelled) throw new Error('Cancelled'); + _lastUiMs = 0; + const cp2 = await convCopyDirWithProgress(patchDir, workerDir, { + getCancelled: () => job._cancelled, + onProgress: ({ index, total, bytesDone, bytesTotal }) => { + const pct = 35 + Math.round((bytesTotal > 0 ? bytesDone / bytesTotal : index / total) * 15); + job.progress.phase = `Applying backpork — ${index}/${total} files (${fmtBytes(bytesDone)} / ${fmtBytes(bytesTotal)})`; + job.progress.percent = pct; + throttled(); + }, + }); + sendConvJobUpdate(job); + logLine(`[2/4] Backpork overlay done — ${cp2.files} files, ${fmtBytes(cp2.bytes)}.`); + } else { + logLine('[2/4] Backpork path not found — skipping overlay.'); + job.progress.percent = 50; + sendConvJobUpdate(job); + } + } else { + logLine('[2/4] No backpork patch — skipping.'); + job.progress.percent = 50; + sendConvJobUpdate(job); + } + + // ── Phase 3: Convert ────────────────────────────────────────────────── + const isFfpfsc = job.mode === 'ffpfsc'; + const modeLabel = isExfat ? 'ExFAT (native)' : isFfpfsc ? 'FFPFSC (compressed PFS, native)' : `FFPKG (${store.get('conv.ufs2Method', 'makefs')})`; + job.progress.phase = `Converting to ${modeLabel}…`; + job.progress.percent = 50; + sendConvJobUpdate(job); + logLine(`[3/4] Converting → ${job.output_file} [mode: ${job.mode}]`); + if (store.get('conv.psNotify.onConvertStart', true)) + convPs5Notify(`${job.game_id} converting`, `Mode: ${job.mode.toUpperCase()}`); + fs.mkdirSync(path.dirname(job.output_file), { recursive: true }); + if (job._cancelled) throw new Error('Cancelled'); + + const onData = (chunk) => { + // Normalise CRLF/CR/LF — same as ufs2ElectronApp so no \n artifacts on Windows bat/PS output + for (const raw of chunk.split(/\r\n|\r|\n/)) { + const line = raw.trimEnd(); + if (line) logLine(line); + } + }; + + if (isExfat) { + // workerDir was populated by copying from the resolved game root (see Phase 1), + // so eboot.bin is guaranteed at the root of workerDir — pass it directly, + // identical to how the standalone ufs2ElectronApp works. + await convBuildExfatNative(job.output_file, workerDir, job, { onData, onProc: p => { job._activeProc = p; } }); + job._activeProc = null; + } else if (isFfpfsc) { + // Native compressed-PFS build — no external tool, no admin required. + await convBuildPfsNative(job.output_file, workerDir, job, { onData }); + } else { + let _lastUfs2Ms = 0; + const onUfs2Progress = ({ toolPct, filesNow, filesTotal, bytesNow, bytesTotal }) => { + job.progress.percent = 50 + Math.round(toolPct * 0.45); + job.progress.phase = `Converting — ${toolPct}% (${filesNow}/${filesTotal} files, ${bytesNow} / ${bytesTotal})`; + const now = Date.now(); + if (now - _lastUfs2Ms >= 150) { _lastUfs2Ms = now; sendConvJobUpdate(job); } + }; + const method = store.get('conv.ufs2Method', 'makefs'); + const onProc = p => { job._activeProc = p; }; + if (method === 'newfs') await convNewfsPS5(workerDir, job.output_file, { onData, onProgress: onUfs2Progress, onProc }); + else await convMakefPS5(workerDir, job.output_file, { onData, onProgress: onUfs2Progress, onProc }); + job._activeProc = null; + sendConvJobUpdate(job); + } + logLine('[3/4] Conversion done.'); + + // ── Phase 4: Cleanup temp ───────────────────────────────────────────── + job.progress.phase = 'Cleaning up…'; + job.progress.percent = 95; + sendConvJobUpdate(job); + logLine(`[4/4] Removing temp: ${workerDir}`); + await fs.promises.rm(workerDir, { recursive: true, force: true }); + logLine('[4/4] Temp removed.'); + + job.progress.phase = 'Complete'; + job.progress.percent = 100; + job.status = 'done'; + job.completedAt = Date.now(); + logLine(`✓ Output: ${job.output_file}`); + if (store.get('conv.psNotify.onJobDone', true)) + convPs5Notify(`${job.game_id} conversion done`, path.basename(job.output_file)); + sendConvJobUpdate(job); + + // ── Optional Phase 5: FTP upload ────────────────────────────────────── + if (job.ftpUpload && job.ftpRemotePath) { + if (!ftp.isConnected() && !ftp.hasCredentials()) { + logLine('⚠ FTP not connected — skipping auto-upload. Upload manually via the Transfers/Install flow.'); + win?.webContents.send('conv:job-upload-skipped', { id: job.id, reason: 'FTP not connected' }); + } else { + const uploadLabel = `${job.game_id} → PS5 (post-conv)`; + const outputPath = job.output_file; + // ftpRemotePath is the destination directory — append the output filename so + // the file lands with its proper extension (e.g. PPSA03644.exfat). + const remoteDest = job.ftpRemotePath.replace(/\/+$/, '') + '/' + path.basename(outputPath); + logLine(`[5] Queuing FTP upload → ${remoteDest}`); + const deleteFlag = job.deleteAfter; + const jobId = job.id; + transferMgr.enqueue('upload', { + label: uploadLabel, + localPath: outputPath, + remotePath: remoteDest, + }).then(() => { + if (deleteFlag) { + try { + fs.unlinkSync(outputPath); + log.info(`[Conv] Deleted converted file after upload: ${outputPath}`); + win?.webContents.send('conv:job-file-deleted', { id: jobId, output_file: outputPath }); + } catch (e) { + log.warn(`[Conv] Could not delete file after upload: ${e.message}`); + } + } + convPs5Notify(`${job.game_id} uploaded to PS5`); + }).catch(e => { + log.warn(`[Conv] Post-conv upload failed: ${e.message}`); + win?.webContents.send('conv:job-upload-failed', { id: jobId, error: e.message }); + }); + } + } + + } catch (e) { + job.status = job._cancelled ? 'cancelled' : 'error'; + job.error = e.message; + job.completedAt = Date.now(); + logLine(`✗ ${e.message}`); + if (store.get('conv.psNotify.onJobDone', true) && !job._cancelled) + convPs5Notify(`${job.game_id} conv failed`, e.message.slice(0, 80)); + try { + if (fs.existsSync(workerDir)) + await fs.promises.rm(workerDir, { recursive: true, force: true }); + } catch (_) {} + // Also delete any partial output file the converter may have created + try { if (job.output_file && fs.existsSync(job.output_file)) fs.unlinkSync(job.output_file); } catch (_) {} + sendConvJobUpdate(job); + } +} + +// ── Conversion IPC ──────────────────────────────────────────────────────────── + +ipcMain.handle('conv:tool-info', () => { + const tp = store.get('conv.ufs2ToolPath', ''); + return { + toolPath: tp, + toolAvailable: conversionCapabilities.ufs2 && !!(tp && fs.existsSync(tp)), + supported: conversionCapabilities.ufs2, + reason: conversionCapabilities.ufs2 ? '' : conversionCapabilities.reason, + }; +}); + +ipcMain.handle('conv:exfat-tool-info', () => { + // ExFAT is built natively on Windows; Linux does not have OSFMount support. + const osf = resolveOsfmountPath(); + const available = conversionCapabilities.exfat && !!osf; + return { + toolPath: osf || store.get('conv.osfmountPath', '') || store.get('conv.exfatToolPath', ''), + osfmountPath: osf, + // Legacy fields kept so the existing settings UI availability indicator works. + batAvailable: available, + psAvailable: available, + toolAvailable: available, + supported: conversionCapabilities.exfat, + reason: conversionCapabilities.exfat ? '' : conversionCapabilities.reason, + native: true, + }; +}); + +ipcMain.handle('conv:tool-pick', async () => { + if (!conversionCapabilities.ufs2) throw new Error(conversionCapabilities.reason); + const { canceled, filePaths } = await dialog.showOpenDialog(win, { + title: 'Select UFS2Tool.exe', + filters: [{ name: 'Executable', extensions: ['exe'] }], + properties: ['openFile'], + }); + if (canceled) return null; + const tp = filePaths[0]; + store.set('conv.ufs2ToolPath', tp); + return { toolPath: tp, toolAvailable: fs.existsSync(tp) }; +}); + +ipcMain.handle('conv:exfat-tool-pick', async () => { + if (!conversionCapabilities.exfat) throw new Error(conversionCapabilities.reason); + // Pick osfmount.com (the only external dependency for native exFAT building). + const { canceled, filePaths } = await dialog.showOpenDialog(win, { + title: 'Select osfmount.com (from your OSFMount install)', + filters: [{ name: 'OSFMount CLI', extensions: ['com', 'exe'] }], + properties: ['openFile'], + }); + if (canceled) return null; + const tp = filePaths[0]; + store.set('conv.osfmountPath', tp); + const available = fs.existsSync(tp); + return { toolPath: tp, osfmountPath: tp, batAvailable: available, psAvailable: available, toolAvailable: available, native: true }; +}); + +ipcMain.handle('conv:pick-output-dir', async () => { + const { canceled, filePaths } = await dialog.showOpenDialog(win, { + title: 'Select Output Directory (where .ffpkg / .exfat files are saved)', + properties: ['openDirectory'], + }); + if (canceled) return null; + store.set('conv.outputDir', filePaths[0]); + return filePaths[0]; +}); + +ipcMain.handle('conv:pick-temp-dir', async () => { + const { canceled, filePaths } = await dialog.showOpenDialog(win, { + title: 'Select Temp / Worker Directory (needs plenty of free space)', + properties: ['openDirectory'], + }); + if (canceled) return null; + store.set('conv.tempDir', filePaths[0]); + return filePaths[0]; +}); + +ipcMain.handle('conv:queue:add', (_e, item) => { + const out = store.get('conv.outputDir', '') || path.join(os.homedir(), 'Downloads', 'pork-converted'); + const ext = item.mode === 'exfat' ? '.exfat' : item.mode === 'ffpfsc' ? '.ffpfsc' : '.ffpkg'; + const suffix = item.firmware_label ? `_${item.firmware_label}` : ''; + const outFile = path.join(out, `${item.game_id}${suffix}${ext}`); + const id = enqueueConvJob({ ...item, output_file: outFile }); + if (store.get('conv.psNotify.onGameQueued', true)) + convPs5Notify(`${item.game_id} queued for conversion`); + return { id, output_file: outFile }; +}); + +ipcMain.handle('conv:queue:add-batch', (_e, items) => { + const out = store.get('conv.outputDir', '') || path.join(os.homedir(), 'Downloads', 'pork-converted'); + const ids = items.map(item => { + const ext = item.mode === 'exfat' ? '.exfat' : item.mode === 'ffpfsc' ? '.ffpfsc' : '.ffpkg'; + const suffix = item.firmware_label ? `_${item.firmware_label}` : ''; + const outFile = path.join(out, `${item.game_id}${suffix}${ext}`); + return enqueueConvJob({ ...item, game_name: item.game_name || item.game_id, output_file: outFile }); + }); + if (items.length === 1 && store.get('conv.psNotify.onGameQueued', true)) + convPs5Notify(`${items[0].game_id} queued for conversion`); + else if (items.length > 1 && store.get('conv.psNotify.onBatchQueued', true)) + convPs5Notify(`${items.length} games queued for conversion`); + return ids; +}); + +ipcMain.handle('conv:queue:list', () => [...convJobs.values()].map(serializeConvJob)); +ipcMain.handle('conv:queue:is-paused', () => convQueuePaused); + +ipcMain.handle('conv:queue:start', () => { + setConvQueuePaused(false); + tickConvQueue(); + return { ok: true }; +}); + +ipcMain.handle('conv:queue:cancel', (_e, { id }) => { + const job = convJobs.get(id); + if (!job) return { success: false }; + job._cancelled = true; + // Kill an in-flight conversion tool so cancel works mid-spawn (not just at + // phase boundaries) and doesn't leave the queue blocked on a hung child. + if (job._activeProc) { convKillProc(job._activeProc); job._activeProc = null; } + if (job.status === 'queued') { + job.status = 'cancelled'; + sendConvJobUpdate(job); + } + return { success: true }; +}); + +ipcMain.handle('conv:queue:clear-done', () => { + for (const [id, job] of convJobs) { + if (['done', 'cancelled', 'error'].includes(job.status)) convJobs.delete(id); + } + return [...convJobs.values()].map(serializeConvJob); +}); + +ipcMain.handle('conv:queue:retry', (_e, { id }) => { + const old = convJobs.get(id); + if (!old) return { success: false }; + const newId = enqueueConvJob({ + game_id: old.game_id, + game_name: old.game_name, + game_path: old.game_path, + backpork_path: old.backpork_path, + firmware_label: old.firmware_label, + output_file: old.output_file, + mode: old.mode, + ftpUpload: old.ftpUpload, + ftpRemotePath: old.ftpRemotePath, + deleteAfter: old.deleteAfter, + }); + return { success: true, newId }; +}); + +ipcMain.handle('conv:queue:cleanup-temp', async () => { + const workerBase = store.get('conv.tempDir', '') || os.tmpdir(); + let deletedDirs = 0, deletedFiles = 0; + const errors = []; + + // 1. Remove leftover pork_conv_* scratch directories in the worker base + try { + if (fs.existsSync(workerBase)) { + for (const name of fs.readdirSync(workerBase)) { + if (/^pork_conv_/.test(name)) { + const full = path.join(workerBase, name); + try { + await fs.promises.rm(full, { recursive: true, force: true }); + deletedDirs++; + } catch (e) { errors.push(`Could not delete ${full}: ${e.message}`); } + } + } + } + } catch (e) { errors.push(`Could not scan temp folder: ${e.message}`); } + + // 2. Remove partial output files belonging to errored jobs in the current session + for (const job of convJobs.values()) { + if (job.status === 'error' && job.output_file) { + try { + if (fs.existsSync(job.output_file)) { fs.unlinkSync(job.output_file); deletedFiles++; } + } catch (e) { errors.push(`Could not delete ${job.output_file}: ${e.message}`); } + } + } + + return { deletedDirs, deletedFiles, errors }; +}); + +}; diff --git a/src/ipc/elf-arsenal.js b/src/ipc/elf-arsenal.js new file mode 100644 index 0000000..4c4ff0f --- /dev/null +++ b/src/ipc/elf-arsenal.js @@ -0,0 +1,165 @@ +'use strict'; + +// ───────────────────────────────────────────────────────────────────────────── +// ELF Arsenal client +// +// ELF Arsenal (git.etawen.dev/soniciso/elf-arsenal) is PS5-side homebrew that +// serves a web UI + REST API on :6969. This module is the Porkfolio-side client: +// all requests go through the main process (node-fetch) to avoid renderer CORS, +// mirroring the API the ELF Arsenal web UI itself uses. +// +// Endpoints used (from elf-arsenal/assets/app.js): +// GET /version +// GET /api/fs/list?path= → { entries|items:[{name,is_dir|dir,size,mtime,mode}], safety_lock } +// GET /api/fs/stat?path= +// GET /api/fs/mkdir?path= +// GET /api/fs/rename?src=&dst= (also used as move within a device) +// GET /api/fs/delete?path=&recursive=1 +// GET /api/fs/copy?src=&dst= (long-running → poll /api/fs/job/status) +// GET /api/fs/move?src=&dst= (long-running → poll /api/fs/job/status) +// GET /api/fs/job/status → { busy, ... } +// GET /api/fs/job/cancel +// GET /api/fs/usb → attached USB mounts +// GET /api/fs/download?path= → raw file bytes +// GET /hbldr?path=&args=&daemon=&pipe=1 → launch an ELF / app +// ───────────────────────────────────────────────────────────────────────────── + +module.exports = function register(ipcMain, { win, store, log, path, fs, dialog, nodeFetch, ps5Notify }) { + const DEFAULT_PORT = 6969; + + function eaBase() { + const ip = store.get('ftp.host', ''); + if (!ip) throw new Error('PS5 IP not set. Configure it in FTP Settings first.'); + const port = store.get('elfArsenal.port', DEFAULT_PORT); + return `http://${ip}:${port}`; + } + + // Fetch with a timeout; returns the raw Response. + async function eaFetch(pathAndQuery, { method = 'GET', body, headers, timeoutMs = 15000 } = {}) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await nodeFetch(eaBase() + pathAndQuery, { method, body, headers, signal: controller.signal }); + } finally { + clearTimeout(timer); + } + } + + // GET → parsed JSON, throwing a useful error on non-2xx / non-JSON. + async function eaJson(pathAndQuery, opts) { + const r = await eaFetch(pathAndQuery, opts); + const text = await r.text(); + let data; + try { data = text ? JSON.parse(text) : {}; } + catch { throw new Error(`ELF Arsenal returned non-JSON (HTTP ${r.status}): ${text.slice(0, 120)}`); } + if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`); + if (data && data.error) throw new Error(data.error); + return data; + } + + const q = encodeURIComponent; + + // Normalize the list response into a stable shape the renderer can rely on. + function normalizeEntries(data) { + const raw = (data.entries || data.items || []).map(i => + typeof i === 'string' ? { name: i } : i); + const entries = raw + .filter(e => e.name !== '.' && e.name !== '..') + .map(e => ({ + name: e.name, + is_dir: e.is_dir !== undefined ? !!e.is_dir : !!e.dir, + size: e.size || 0, + mtime: e.mtime || 0, + mode: e.mode || '', + })); + return { entries, safety_lock: data.safety_lock === true || data.safety_lock === 'true' }; + } + + // ── Embed URL / connectivity ─────────────────────────────────────────────── + ipcMain.handle('ea:embed-url', async () => { + try { + const base = eaBase(); + const r = await eaFetch('/version', { timeoutMs: 5000 }); + if (!r.ok) return { connected: false, error: `ELF Arsenal responded with HTTP ${r.status}.` }; + const version = await r.json().catch(() => ({})); + return { connected: true, url: `${base}/`, version: version.tag || version.version || '' }; + } catch (error) { + return { connected: false, error: error.message }; + } + }); + + // ── Connectivity / version ───────────────────────────────────────────────── + ipcMain.handle('ea:version', async () => { + try { + const r = await eaFetch('/version', { timeoutMs: 5000 }); + if (!r.ok) return { connected: false }; + const v = await r.json().catch(() => ({})); + return { connected: true, ...v }; + } catch (e) { + return { connected: false, error: e.message }; + } + }); + + // ── Directory / file ops ─────────────────────────────────────────────────── + ipcMain.handle('ea:list', (_e, { path: p }) => eaJson(`/api/fs/list?path=${q(p)}`).then(normalizeEntries)); + ipcMain.handle('ea:stat', (_e, { path: p }) => eaJson(`/api/fs/stat?path=${q(p)}`)); + ipcMain.handle('ea:usb', () => eaJson('/api/fs/usb')); + ipcMain.handle('ea:mkdir', (_e, { path: p }) => eaJson(`/api/fs/mkdir?path=${q(p)}`)); + ipcMain.handle('ea:rename',(_e, { src, dst }) => eaJson(`/api/fs/rename?src=${q(src)}&dst=${q(dst)}`)); + ipcMain.handle('ea:delete',(_e, { path: p, recursive = true }) => + eaJson(`/api/fs/delete?path=${q(p)}${recursive ? '&recursive=1' : ''}`)); + ipcMain.handle('ea:copy', (_e, { src, dst }) => eaJson(`/api/fs/copy?src=${q(src)}&dst=${q(dst)}`)); + ipcMain.handle('ea:move', (_e, { src, dst }) => eaJson(`/api/fs/move?src=${q(src)}&dst=${q(dst)}`)); + ipcMain.handle('ea:job-status', () => eaJson('/api/fs/job/status').catch(() => ({ busy: false }))); + ipcMain.handle('ea:job-cancel', () => eaFetch('/api/fs/job/cancel').then(() => ({ ok: true })).catch(() => ({ ok: false }))); + + // ── Launch an ELF / app via hbldr ────────────────────────────────────────── + ipcMain.handle('ea:launch', async (_e, { path: p, args = null, daemon = false }) => { + const params = new URLSearchParams({ pipe: '1', daemon: daemon ? '1' : '0', path: p }); + if (args) params.append('args', Array.isArray(args) ? args.map(a => a.replace(/ /g, '\\ ')).join(' ') : String(args).replace(/ /g, '\\ ')); + const r = await eaFetch('/hbldr?' + params.toString(), { timeoutMs: 30000 }); + if (!r.ok) { + let detail = ''; + try { const j = await r.json(); if (j && j.error) detail = ' — ' + j.error; } catch {} + throw new Error(`Launch failed (HTTP ${r.status})${detail}`); + } + ps5Notify?.('ELF Arsenal', `Launched ${path.basename(p)}`).catch?.(() => {}); + return { ok: true }; + }); + + // ── Download a remote file to the PC (save dialog) ───────────────────────── + ipcMain.handle('ea:download', async (_e, { path: remotePath, filename }) => { + const { canceled, filePath } = await dialog.showSaveDialog(win, { + defaultPath: filename || path.basename(remotePath), + }); + if (canceled || !filePath) return { canceled: true }; + const r = await eaFetch(`/api/fs/download?path=${q(remotePath)}`, { timeoutMs: 5 * 60 * 1000 }); + if (!r.ok) throw new Error(`Download failed: HTTP ${r.status}`); + await new Promise((resolve, reject) => { + const out = fs.createWriteStream(filePath); + out.on('error', reject); + out.on('finish', resolve); + r.body.on('error', reject); + r.body.pipe(out); + }); + return { ok: true, filePath }; + }); + + // ── Upload a local file to a remote directory ────────────────────────────── + // ELF Arsenal accepts a POST to /api/fs/upload with the target path in the + // query and the raw bytes as the body. + ipcMain.handle('ea:upload', async (_e, { localPath, remotePath }) => { + if (!fs.existsSync(localPath)) throw new Error(`Local file not found: ${localPath}`); + const data = await fs.promises.readFile(localPath); + const r = await eaFetch(`/api/fs/upload?path=${q(remotePath)}`, { + method: 'POST', + body: data, + headers: { 'Content-Type': 'application/octet-stream' }, + timeoutMs: 5 * 60 * 1000, + }); + if (!r.ok) throw new Error(`Upload failed: HTTP ${r.status}`); + return { ok: true, bytes: data.length }; + }); + + log.info('[ELF Arsenal] IPC handlers registered'); +}; diff --git a/src/ipc/garlic-saves.js b/src/ipc/garlic-saves.js new file mode 100644 index 0000000..e525eea --- /dev/null +++ b/src/ipc/garlic-saves.js @@ -0,0 +1,7 @@ +'use strict'; +module.exports = function register(ipcMain, { win, store, ftp, path, fs, app, net, github, ps5Notify }) { +// ── GarlicSaves ──────────────────────────────────────────────────────────────── +ipcMain.handle('garlic:start', async () => { + throw Object.assign(new Error('GarlicSaves Worker is uitgeschakeld totdat upstream unieke enrollment en aantoonbaar versleuteld transport ondersteunt.'), { code: 'GARLIC_SECURE_ENROLLMENT_REQUIRED' }); +}); +}; diff --git a/src/ipc/media.js b/src/ipc/media.js new file mode 100644 index 0000000..ec74dcf --- /dev/null +++ b/src/ipc/media.js @@ -0,0 +1,838 @@ +'use strict'; + +// Manual form construction for file uploads. Explicit knownLength forces +// Content-Length, avoiding chunked transfer encoding that catbox's server has +// historically mishandled for large PS5 clips. +const _nodeFetch = require('node-fetch'); +const _NodeFormData = require('form-data'); +const { createMediaCacheService } = require('../services/media-cache-service'); + +module.exports = function register(ipcMain, { win, store, getDesktopSecret, log, ftp, shell, dialog, path, fs, os, http, https, ffmpegPath, spawn, transferMgr, ps5Notify, eNet, app, BrowserWindow }) { +// ── Media (Screenshots & Clips) ─────────────────────────────────────────────── + +// Recursively walk an FTP directory, collecting all files whose extension +// matches `ext`. The PS5 stores media like: +// /user/av_contents/photo/{AppId}/{GameId}/{hashFolder}/{file.jpg} +// /user/av_contents/video/{AppId}/{GameId}/{hashFolder}/{file.webm} +// We extract game_id from the folder that looks like a PPSA/CUSA id (depth 2 +// from base), but fall back to whatever is at depth 1 if nothing matches. +async function scanMediaDir(basePath, type, ext, items) { + const GAME_ID_RE = /^[A-Z]{2,4}\d{5}/i; + async function recurse(dirPath, depth) { + if (depth > 6) return; // safety cap + let entries; + try { entries = await ftp.listDir(dirPath); } catch (_) { return; } + for (const entry of entries) { + const childPath = `${dirPath}/${entry.name}`; + if (entry.isDir) { + await recurse(childPath, depth + 1); + } else { + const entryExt = entry.name.split('.').pop().toLowerCase(); + if (entryExt === ext) { + // Extract game_id from path segments relative to basePath + const rel = dirPath.slice(basePath.length).replace(/^\//, ''); + const segs = rel.split('/').filter(Boolean); + // Prefer the segment that looks like a game ID (PPSA/CUSA); fall back to seg[1] → seg[0] + const game_id = segs.find(s => GAME_ID_RE.test(s)) + || segs[1] || segs[0] || 'Unknown'; + + // Parse capture timestamp from PS5 filename: YYYYMMDD_HHMMSS_xxxxxxxx.ext + // This is more reliable than FTP modifiedAt which may be missing or offset. + let capturedAt = null; + const fnMatch = entry.name.match(/^(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})/); + if (fnMatch) { + const [, yr, mo, dy, hr, mn, sc] = fnMatch; + capturedAt = new Date(`${yr}-${mo}-${dy}T${hr}:${mn}:${sc}`).getTime(); + } else if (entry.modifiedAt instanceof Date) { + capturedAt = entry.modifiedAt.getTime(); + } + + items.push({ type, game_id, filename: entry.name, size: entry.size, remotePath: childPath, capturedAt }); + win?.webContents.send('media:scan:progress', { game_id, type, found: items.length }); + } + } + } + } + await recurse(basePath, 0); +} + +ipcMain.handle('media:scan', async () => { + if (!ftp.isConnected() && !ftp.hasCredentials()) throw new Error('Not connected to FTP. Connect to your PS5 first.'); + + const SOURCES = [ + { dir: '/user/av_contents/photo', type: 'screenshot', ext: 'jpg' }, + { dir: '/user/av_contents/video', type: 'clip', ext: 'webm' }, + ]; + const items = []; + for (const src of SOURCES) await scanMediaDir(src.dir, src.type, src.ext, items); + + if (store.get('media.psNotify.enabled', true)) { + const shots = items.filter(i => i.type === 'screenshot').length; + const clips = items.filter(i => i.type === 'clip').length; + const parts = []; + if (shots) parts.push(`${shots} screenshot${shots !== 1 ? 's' : ''}`); + if (clips) parts.push(`${clips} clip${clips !== 1 ? 's' : ''}`); + ps5Notify('Media Scan Complete', parts.length ? parts.join(', ') : 'No media found'); + } + + return items; +}); + +// Media file cache — shared by fetch-thumb, get-cache-path, open, discord-send +const _mediaCacheDir = path.join(os.tmpdir(), 'porkfolio-media-thumb'); +const _mediaCacheManager = createMediaCacheService({ root: _mediaCacheDir, indexPath: path.join(os.tmpdir(), 'porkfolio-media-cache-index.json') }); + +// Ensure a remote file is in the local cache; returns the local path. +async function _ensureMediaCached(remotePath) { + const safe = remotePath.replace(/[^a-zA-Z0-9._-]/g, '_'); + const cached = await _mediaCacheManager.ensureCached({ + key: `full:${remotePath}`, relativePath: safe, kind: 'cached', + origin: { type: 'remote', provider: 'ps5-ftp', locator: remotePath }, + fetchTo: temporary => ftp.downloadFile(remotePath, temporary), + }); + return cached.localPath; +} + +ipcMain.handle('media:cache:status', () => _mediaCacheManager.status()); +ipcMain.handle('media:cache:pin', (_e, input) => _mediaCacheManager.setPinned(input?.id, input?.pinned !== false)); +ipcMain.handle('media:cache:clear', (_e, input) => _mediaCacheManager.clear(input || {})); +ipcMain.handle('media:cache:retention', (_e, input) => _mediaCacheManager.applyRetention(input || {})); + +// Screenshot thumbnails — returns a data URL (JPEGs are small, ≤2 MB). +// Video clips must use media:get-cache-path + the pork-cache:// protocol +// instead; encoding a 30 MB webm as base64 over IPC is not viable. +ipcMain.handle('media:fetch-thumb', async (_e, { remotePath }) => { + if (!ftp.isConnected() && !ftp.hasCredentials()) throw new Error('Not connected'); + const ext = path.extname(remotePath).slice(1).toLowerCase(); + if (ext === 'webm') throw new Error('Use media:get-cache-path for video files'); + const localPath = await _ensureMediaCached(remotePath); + const buf = fs.readFileSync(localPath); + const mime = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : `image/${ext}`; + return `data:${mime};base64,${buf.toString('base64')}`; +}); + +// Returns the safe filename for use in pork-cache:// URLs. +// Downloads the file first if not already cached. +ipcMain.handle('media:get-cache-path', async (_e, { remotePath }) => { + if (!ftp.isConnected() && !ftp.hasCredentials()) throw new Error('Not connected'); + const localPath = await _ensureMediaCached(remotePath); + return path.basename(localPath); // just the filename — renderer builds the URL +}); + +// Download just the first 4 MB of a webm clip for thumbnail extraction. +// The partial file is enough for Chromium's

    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 VbONPV?BoCd002ovPDHLkV1l5LSBn4u literal 0 HcmV?d00001 diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..2107f39 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,28 @@ +services: + chimera: + build: . + ports: ["127.0.0.1:6967:6967"] + environment: + HOST: 0.0.0.0 + PORT: "6967" + CHIMERA_DATA_DIR: /data + CHIMERA_PUBLIC_ORIGIN: ${CHIMERA_PUBLIC_ORIGIN:?Set the HTTPS reverse proxy origin} + CHIMERA_ALLOWED_HOSTS: ${CHIMERA_ALLOWED_HOSTS:?Set the public host plus localhost} + CHIMERA_COOKIE_SECURE: "1" + CHIMERA_TRUST_PROXY: "1" + APP_SECRET_KEY_FILE: /run/secrets/master_key + CHIMERA_BOOTSTRAP_TOKEN_FILE: /run/secrets/bootstrap_token + volumes: ["chimera-data:/data"] + secrets: [master_key, bootstrap_token] + read_only: true + tmpfs: ["/tmp"] + cap_drop: [ALL] + security_opt: ["no-new-privileges:true"] + restart: unless-stopped +secrets: + master_key: + file: ${CHIMERA_SECRET_KEY_PATH:?Set an external master key file} + bootstrap_token: + file: ${CHIMERA_BOOTSTRAP_TOKEN_PATH:?Set an external bootstrap token file} +volumes: + chimera-data: diff --git a/docs/assets/chimera-overview.jpg b/docs/assets/chimera-overview.jpg new file mode 100644 index 0000000000000000000000000000000000000000..9919d9aa5fab762ca64b34d30610c393097766d3 GIT binary patch literal 96072 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 p;{w)*3pqf?Ro<|)blzv`cOL&7`)^h9Bz^z@ literal 0 HcmV?d00001 diff --git a/donation-preload.js b/donation-preload.js new file mode 100644 index 0000000..ee09927 --- /dev/null +++ b/donation-preload.js @@ -0,0 +1,17 @@ +'use strict'; + +const { contextBridge, ipcRenderer } = require('electron'); + +// ── Bitcoin address to encode in the QR code ──────────────────────────────── +// Replace this with your actual Bitcoin address. +const BITCOIN_ADDRESS = '3Kt8L3FRS12XW6HJt9QqFGd8irxBc8xTD4'; + +contextBridge.exposeInMainWorld('donate', { + openX: () => ipcRenderer.send('donation:open-x'), + openRevolut: () => ipcRenderer.send('donation:open-revolut'), + dismiss: () => ipcRenderer.send('donation:dismiss'), + exitApp: () => ipcRenderer.send('donation:exit-app'), + btcAddress: () => BITCOIN_ADDRESS, + // QR is generated as SVG in the main process (no canvas required) + genQR: () => ipcRenderer.invoke('donation:gen-qr'), +}); diff --git a/main.js b/main.js new file mode 100644 index 0000000..397cbae --- /dev/null +++ b/main.js @@ -0,0 +1,3416 @@ +'use strict'; + +const { app, BrowserWindow, ipcMain, shell, dialog, net: eNet, protocol, safeStorage } = require('electron'); +const { getConversionCapabilities } = require('./src/platform-capabilities'); + +// The SteamOS/Wayland validation host repeatedly rejected Electron's GPU child +// process (error 1002), which makes Chromium terminate after its retry limit. +// Software compositing is the portable fallback; Windows keeps its normal GPU path. +if (getConversionCapabilities().disableHardwareAcceleration) { + app.disableHardwareAcceleration(); +} + +// Register pork-cache as a privileged scheme before app is ready so that +//