diff --git a/backend/endpoints/sync/__init__.py b/backend/endpoints/sync/__init__.py index 03a6b43a5..0122766ce 100644 --- a/backend/endpoints/sync/__init__.py +++ b/backend/endpoints/sync/__init__.py @@ -109,6 +109,23 @@ class SyncNegotiatePayload(BaseModel): f"saves. At most {MAX_ROM_IDS_PER_QUERY} IDs per request." ), ) + restore_unlisted: bool = Field( + default=False, + description=( + "Offer every current server save the client did not list as a " + "download, even one this device already synced. For clients that " + "never delete saves themselves (such as a browser, whose storage " + "can be evicted), so a missing save means lost rather than deleted." + ), + ) + emulators: list[str] | None = Field( + default=None, + description=( + "Emulators whose saves this client can load. When provided, only " + "server saves written by one of them are paired or offered, so a " + "save from another emulator in the same slot is left alone." + ), + ) # Its own class so the OpenAPI schema keeps the name clients generate from. @@ -218,6 +235,8 @@ def negotiate_sync( server_saves = db_save_handler.get_saves( user_id=request.user.id, slot_not_null=True, rom_ids=rom_id_scope ) + if payload.emulators is not None: + server_saves = [s for s in server_saves if s.emulator in payload.emulators] server_save_map: dict[tuple[int, str | None], Save] = {} for save in server_saves: key = (save.rom_id, save.slot) @@ -340,7 +359,7 @@ def negotiate_sync( if device_sync: synced_ts = to_utc(device_sync.last_synced_at) save_ts = to_utc(save.updated_at) - if save_ts <= synced_ts: + if save_ts <= synced_ts and not payload.restore_unlisted: # Save hasn't changed since device last synced - client deleted it continue diff --git a/backend/tests/endpoints/test_sync.py b/backend/tests/endpoints/test_sync.py index 83e2df882..47dab72b1 100644 --- a/backend/tests/endpoints/test_sync.py +++ b/backend/tests/endpoints/test_sync.py @@ -1032,6 +1032,86 @@ class TestNegotiateAdvanced: ops_for_save = [op for op in data["operations"] if op.get("save_id") == save.id] assert len(ops_for_save) == 0 + def test_negotiate_restores_a_synced_save_the_client_lost( + self, client, access_token: str, admin_user: User, save: Save + ): + device = db_device_handler.add_device( + Device(id="neg-restore-dev", user_id=admin_user.id, sync_enabled=True) + ) + db_device_save_sync_handler.upsert_sync( + device_id=device.id, + save_id=save.id, + synced_at=datetime.now(timezone.utc), + ) + + response = client.post( + "/api/sync/negotiate", + json={"device_id": device.id, "saves": [], "restore_unlisted": True}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + ops_for_save = [ + op for op in response.json()["operations"] if op.get("save_id") == save.id + ] + assert [op["action"] for op in ops_for_save] == ["download"] + + def test_negotiate_scopes_server_saves_to_the_listed_emulators( + self, client, access_token: str, admin_user: User, rom: Rom, platform + ): + db_device_handler.add_device( + Device(id="neg-emu-dev", user_id=admin_user.id, sync_enabled=True) + ) + native = make_save( + rom, + admin_user, + "native.sav", + emulator="retroarch", + slot="autosave", + content_hash="NATIVE", + file_path=f"{platform.slug}/saves/retroarch", + file_size_bytes=1.0, + ) + browser = make_save( + rom, + admin_user, + "browser.sav", + emulator="jsdos", + slot="quicksave", + content_hash="BROWSER", + file_path=f"{platform.slug}/saves/jsdos", + file_size_bytes=1.0, + ) + + response = client.post( + "/api/sync/negotiate", + json={ + "device_id": "neg-emu-dev", + "rom_ids": [rom.id], + "emulators": ["jsdos"], + "saves": [ + { + "rom_id": rom.id, + "file_name": "autosave.changes", + "slot": "autosave", + "emulator": "jsdos", + "content_hash": "LOCAL", + "updated_at": datetime.now(timezone.utc).isoformat(), + "file_size_bytes": 1, + } + ], + }, + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + operations = response.json()["operations"] + assert native.id not in {op.get("save_id") for op in operations} + actions = {(op["slot"], op["action"]) for op in operations} + assert ("autosave", "upload") in actions + assert ("quicksave", "download") in actions + assert browser.id in {op.get("save_id") for op in operations} + def test_negotiate_matches_untagged_client_to_tagged_server_saves( self, client, access_token: str, admin_user: User, rom: Rom, platform ): diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 840ea9dae..ca857127d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20,6 +20,7 @@ "country-flag-emoji-polyfill": "^0.1.10", "cronstrue": "^3.0.0", "date-fns": "^4.1.0", + "fflate": "^0.8.3", "js-cookie": "^3.0.7", "lodash": "^4.18.1", "md-editor-v3": "^7.0.0", @@ -28,6 +29,7 @@ "qrcode": "^1.5.4", "semver": "^7.6.2", "socket.io-client": "^4.7.5", + "spark-md5": "^3.0.2", "tailwindcss": "^4.0.0", "vanilla-tilt": "^1.8.1", "vue": "^3.5.43", @@ -49,6 +51,7 @@ "@types/node": "^24.0.0", "@types/qrcode": "^1.5.5", "@types/semver": "^7.7.0", + "@types/spark-md5": "^3.0.5", "@vitejs/plugin-vue": "^6.0.7", "@vitest/ui": "^5.0.1", "@vue/test-utils": "^2.4.9", @@ -60,6 +63,7 @@ "eslint-plugin-import-x": "^4.17.1", "eslint-plugin-vue": "^10.8.0", "eslint-plugin-vuejs-accessibility": "^2.5.0", + "fake-indexeddb": "^6.2.5", "globals": "^17.0.0", "happy-dom": "^20.9.0", "openapi-typescript-codegen": "^0.29.0", @@ -5422,6 +5426,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/spark-md5": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/spark-md5/-/spark-md5-3.0.5.tgz", + "integrity": "sha512-lWf05dnD42DLVKQJZrDHtWFidcLrHuip01CtnC2/S6AMhX4t9ZlEUj4iuRlAnts0PQk7KESOqKxeGE/b6sIPGg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -8498,6 +8509,16 @@ "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==", "license": "MIT" }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -8557,7 +8578,6 @@ "version": "0.8.3", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", - "dev": true, "license": "MIT" }, "node_modules/file-entry-cache": { @@ -12156,6 +12176,12 @@ "source-map": "^0.6.0" } }, + "node_modules/spark-md5": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/spark-md5/-/spark-md5-3.0.2.tgz", + "integrity": "sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==", + "license": "(WTFPL OR MIT)" + }, "node_modules/stable-hash-x": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6a0468ac7..8308ba52a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -50,6 +50,7 @@ "country-flag-emoji-polyfill": "^0.1.10", "cronstrue": "^3.0.0", "date-fns": "^4.1.0", + "fflate": "^0.8.3", "js-cookie": "^3.0.7", "lodash": "^4.18.1", "md-editor-v3": "^7.0.0", @@ -58,6 +59,7 @@ "qrcode": "^1.5.4", "semver": "^7.6.2", "socket.io-client": "^4.7.5", + "spark-md5": "^3.0.2", "tailwindcss": "^4.0.0", "vanilla-tilt": "^1.8.1", "vue": "^3.5.43", @@ -79,6 +81,7 @@ "@types/node": "^24.0.0", "@types/qrcode": "^1.5.5", "@types/semver": "^7.7.0", + "@types/spark-md5": "^3.0.5", "@vitejs/plugin-vue": "^6.0.7", "@vitest/ui": "^5.0.1", "@vue/test-utils": "^2.4.9", @@ -90,6 +93,7 @@ "eslint-plugin-import-x": "^4.17.1", "eslint-plugin-vue": "^10.8.0", "eslint-plugin-vuejs-accessibility": "^2.5.0", + "fake-indexeddb": "^6.2.5", "globals": "^17.0.0", "happy-dom": "^20.9.0", "openapi-typescript-codegen": "^0.29.0", diff --git a/frontend/src/__generated__/models/SyncNegotiatePayload.ts b/frontend/src/__generated__/models/SyncNegotiatePayload.ts index 00f20af47..4cde76edd 100644 --- a/frontend/src/__generated__/models/SyncNegotiatePayload.ts +++ b/frontend/src/__generated__/models/SyncNegotiatePayload.ts @@ -16,5 +16,13 @@ export type SyncNegotiatePayload = { * IDs of the ROMs installed on the device. When provided, downloads are offered only for these ROMs (plus any ROM the client sent a save for) instead of the user's whole save library. This is a read-only scope: omitting a ROM never deletes or unlinks its saves. At most 500 IDs per request. */ rom_ids?: (Array | null); + /** + * Offer every current server save the client did not list as a download, even one this device already synced. For clients that never delete saves themselves (such as a browser, whose storage can be evicted), so a missing save means lost rather than deleted. + */ + restore_unlisted?: boolean; + /** + * Emulators whose saves this client can load. When provided, only server saves written by one of them are paired or offered, so a save from another emulator in the same slot is left alone. + */ + emulators?: (Array | null); }; diff --git a/frontend/src/locales/bg_BG/play.json b/frontend/src/locales/bg_BG/play.json index fd9ec600d..dc9587539 100644 --- a/frontend/src/locales/bg_BG/play.json +++ b/frontend/src/locales/bg_BG/play.json @@ -43,7 +43,7 @@ "desktop-title": "Container desktop", "discs": "Дискове", "download-memory-card": "Изтегляне на картата", - "easyrpg-browser-save-warning": "Запазените данни на EasyRPG се съхраняват само в този браузър и не се синхронизират с RomM.", + "easyrpg-saves-load-failed": "Записите ви не можаха да бъдат заредени от RomM.", "easyrpg-screen": "Игрален екран на EasyRPG", "edit-memory-card": "Редактиране на карта памет", "emulator": "Емулатор", @@ -65,7 +65,6 @@ "import-refusals-truncated": "Още {count} проблем не е показан | Още {count} проблема не са показани", "join-closed": "Тази сесия вече не е отворена за други играчи.", "join-ended": "Тази сесия приключи.", - "jsdos-browser-save-warning": "Запазените данни на js-dos се съхраняват само в този браузър и не се синхронизират с RomM.", "jsdos-quit-without-saving": "Изход без запазване на скорошния напредък?", "keep-playing": "Продължи играта", "last-save-synced": "Последният запис за {game} е синхронизиран.", @@ -174,6 +173,7 @@ "powered-by": "Захранено от", "quick-state-loaded": "Бързият запис от слота е зареден", "quit": "Излез", + "quit-before-save-synced": "Изход, преди последният ви запис да стигне до RomM?", "restart": "Рестартиране", "resume": "Продължаване", "resume-failed": "Избраното състояние не можа да бъде заредено. Играта започна отначало.", diff --git a/frontend/src/locales/bg_BG/settings.json b/frontend/src/locales/bg_BG/settings.json index 41b39d8b8..1ed70cd6d 100644 --- a/frontend/src/locales/bg_BG/settings.json +++ b/frontend/src/locales/bg_BG/settings.json @@ -151,6 +151,18 @@ "device-auth-no-code": "No pairing code provided.", "device-auth-scopes-label": "This device is requesting:", "device-auth-subheading": "A device is requesting access to your RomM library.", + "device-confirm-delete": "Премахване на {name}? Запазените данни остават в RomM, но устройството трябва да се регистрира или сдвои отново, за да синхронизира.", + "device-delete-failed": "Устройството не можа да бъде премахнато: {detail}", + "device-deleted": "Устройството е премахнато", + "device-last-seen": "Последно видяно", + "device-rename": "Преименуване на устройството", + "device-sync": "Синхронизиране", + "device-this-browser": "Този браузър", + "device-unnamed": "Устройство без име", + "device-update-failed": "Устройството не можа да бъде обновено: {detail}", + "devices": "Устройства", + "devices-description": "Приложения и браузъри, които синхронизират запазените данни с профила ви. Браузърът се появява тук, когато за първи път стартира игра.", + "devices-empty": "Все още няма устройства.", "disable-animations": "Изключи анимациите", "disable-animations-desc": "Изключи всички анимации в галерията (въртящ се CD, зареждане на касета, видео при посочване)", "disable-user": "Деактивирай {username}", diff --git a/frontend/src/locales/cs_CZ/play.json b/frontend/src/locales/cs_CZ/play.json index ec327ba7a..0bfa96531 100644 --- a/frontend/src/locales/cs_CZ/play.json +++ b/frontend/src/locales/cs_CZ/play.json @@ -43,7 +43,7 @@ "desktop-title": "Container desktop", "discs": "Disky", "download-memory-card": "Stáhnout kartu", - "easyrpg-browser-save-warning": "Pozice EasyRPG se ukládají pouze v tomto prohlížeči a nesynchronizují se s RomM.", + "easyrpg-saves-load-failed": "Vaše uložené pozice se nepodařilo načíst z RomM.", "easyrpg-screen": "Obrazovka hry EasyRPG", "edit-memory-card": "Upravit paměťovou kartu", "emulator": "Emulátor", @@ -65,7 +65,6 @@ "import-refusals-truncated": "{count} dalších problémů není zobrazeno | {count} další problém není zobrazen | {count} další problémy nejsou zobrazeny | {count} dalších problémů není zobrazeno", "join-closed": "Tato relace už není otevřená dalším hráčům.", "join-ended": "Tato relace skončila.", - "jsdos-browser-save-warning": "Pozice js-dos se ukládají pouze v tomto prohlížeči a nesynchronizují se s RomM.", "jsdos-quit-without-saving": "Ukončit bez uložení posledního postupu?", "keep-playing": "Pokračovat ve hře", "last-save-synced": "Poslední uložená pozice pro {game} byla synchronizována.", @@ -174,6 +173,7 @@ "powered-by": "Poháněno", "quick-state-loaded": "Rychlý stav načten", "quit": "Ukončit", + "quit-before-save-synced": "Ukončit dříve, než se poslední uložená pozice dostane do RomM?", "restart": "Restartovat", "resume": "Pokračovat", "resume-failed": "Vybraný stav se nepodařilo načíst. Hra začala od začátku.", diff --git a/frontend/src/locales/cs_CZ/settings.json b/frontend/src/locales/cs_CZ/settings.json index bda700717..03e7debc0 100644 --- a/frontend/src/locales/cs_CZ/settings.json +++ b/frontend/src/locales/cs_CZ/settings.json @@ -151,6 +151,18 @@ "device-auth-no-code": "No pairing code provided.", "device-auth-scopes-label": "This device is requesting:", "device-auth-subheading": "A device is requesting access to your RomM library.", + "device-confirm-delete": "Odebrat {name}? Jeho pozice zůstanou v RomM, ale pro synchronizaci se musí znovu zaregistrovat nebo spárovat.", + "device-delete-failed": "Zařízení se nepodařilo odebrat: {detail}", + "device-deleted": "Zařízení odebráno", + "device-last-seen": "Naposledy viděno", + "device-rename": "Přejmenovat zařízení", + "device-sync": "Synchronizace", + "device-this-browser": "Tento prohlížeč", + "device-unnamed": "Nepojmenované zařízení", + "device-update-failed": "Zařízení se nepodařilo aktualizovat: {detail}", + "devices": "Zařízení", + "devices-description": "Aplikace a prohlížeče, které synchronizují pozice s vaším účtem. Prohlížeč se zde objeví, jakmile poprvé spustí hru.", + "devices-empty": "Zatím žádná zařízení.", "disable-animations": "Zakázat animace", "disable-animations-desc": "Zakáže všechny animace v galerii (otáčení CD, vkládání cartridge, hover videa)", "disable-user": "Zakázat {username}", diff --git a/frontend/src/locales/de_DE/play.json b/frontend/src/locales/de_DE/play.json index f88c42fb5..29f3781e7 100644 --- a/frontend/src/locales/de_DE/play.json +++ b/frontend/src/locales/de_DE/play.json @@ -43,7 +43,7 @@ "desktop-title": "Container desktop", "discs": "Discs", "download-memory-card": "Karte herunterladen", - "easyrpg-browser-save-warning": "EasyRPG-Spielstände werden nur in diesem Browser gespeichert und nicht mit RomM synchronisiert.", + "easyrpg-saves-load-failed": "Die Speicherstände konnten nicht aus RomM geladen werden.", "easyrpg-screen": "EasyRPG-Spielbildschirm", "edit-memory-card": "Speicherkarte bearbeiten", "emulator": "Emulator", @@ -65,7 +65,6 @@ "import-refusals-truncated": "{count} weiteres Problem nicht angezeigt | {count} weitere Probleme nicht angezeigt", "join-closed": "Diese Sitzung ist nicht mehr für andere Spieler offen.", "join-ended": "Diese Sitzung ist beendet.", - "jsdos-browser-save-warning": "js-dos-Spielstände werden nur in diesem Browser gespeichert und nicht mit RomM synchronisiert.", "jsdos-quit-without-saving": "Beenden, ohne den letzten Fortschritt zu speichern?", "keep-playing": "Weiterspielen", "last-save-synced": "Letzter Spielstand für {game} synchronisiert.", @@ -174,6 +173,7 @@ "powered-by": "Bereitgestellt von", "quick-state-loaded": "Schnellzustand geladen", "quit": "Beenden", + "quit-before-save-synced": "Beenden, bevor der letzte Speicherstand RomM erreicht?", "restart": "Neustart", "resume": "Fortsetzen", "resume-failed": "Der ausgewählte Spielstand konnte nicht geladen werden. Das Spiel wurde neu gestartet.", diff --git a/frontend/src/locales/de_DE/settings.json b/frontend/src/locales/de_DE/settings.json index 83d6f0626..97d17b276 100644 --- a/frontend/src/locales/de_DE/settings.json +++ b/frontend/src/locales/de_DE/settings.json @@ -151,6 +151,18 @@ "device-auth-no-code": "No pairing code provided.", "device-auth-scopes-label": "This device is requesting:", "device-auth-subheading": "A device is requesting access to your RomM library.", + "device-confirm-delete": "{name} entfernen? Die Spielstände bleiben in RomM, aber das Gerät muss sich zum Synchronisieren erneut registrieren oder koppeln.", + "device-delete-failed": "Das Gerät konnte nicht entfernt werden: {detail}", + "device-deleted": "Gerät entfernt", + "device-last-seen": "Zuletzt gesehen", + "device-rename": "Gerät umbenennen", + "device-sync": "Synchronisieren", + "device-this-browser": "Dieser Browser", + "device-unnamed": "Unbenanntes Gerät", + "device-update-failed": "Das Gerät konnte nicht aktualisiert werden: {detail}", + "devices": "Geräte", + "devices-description": "Apps und Browser, die Spielstände mit deinem Konto synchronisieren. Ein Browser erscheint hier, sobald er zum ersten Mal ein Spiel startet.", + "devices-empty": "Noch keine Geräte.", "disable-animations": "Animationen deaktivieren", "disable-animations-desc": "Alle Animationen in der Galerie deaktivieren (CD-Rotation, Kassettenladen, Hover-Videos)", "disable-user": "{username} deaktivieren", diff --git a/frontend/src/locales/en_GB/play.json b/frontend/src/locales/en_GB/play.json index 8e8eda708..9fcd372ea 100644 --- a/frontend/src/locales/en_GB/play.json +++ b/frontend/src/locales/en_GB/play.json @@ -43,7 +43,7 @@ "desktop-title": "Container desktop", "discs": "Discs", "download-memory-card": "Download card", - "easyrpg-browser-save-warning": "EasyRPG saves are stored only in this browser and are not synced with RomM.", + "easyrpg-saves-load-failed": "Could not load your saves from RomM.", "easyrpg-screen": "EasyRPG game screen", "edit-memory-card": "Edit memory card", "emulator": "Emulator", @@ -65,7 +65,6 @@ "import-refusals-truncated": "{count} more issue not shown | {count} more issues not shown", "join-closed": "That session is no longer open to other players.", "join-ended": "That session has ended.", - "jsdos-browser-save-warning": "js-dos saves are stored only in this browser and are not synced with RomM.", "jsdos-quit-without-saving": "Quit without saving recent progress?", "keep-playing": "Keep playing", "last-save-synced": "Last save synced for {game}.", @@ -174,6 +173,7 @@ "powered-by": "Powered by", "quick-state-loaded": "Quick state loaded", "quit": "Quit", + "quit-before-save-synced": "Quit before your latest save reaches RomM?", "restart": "Restart", "resume": "Resume", "resume-failed": "Could not load the selected state. The game started fresh.", diff --git a/frontend/src/locales/en_GB/settings.json b/frontend/src/locales/en_GB/settings.json index 8f71dc3de..8b49ae774 100644 --- a/frontend/src/locales/en_GB/settings.json +++ b/frontend/src/locales/en_GB/settings.json @@ -151,6 +151,18 @@ "device-auth-no-code": "No pairing code provided.", "device-auth-scopes-label": "This device is requesting:", "device-auth-subheading": "A device is requesting access to your RomM library.", + "device-confirm-delete": "Remove {name}? Its saves stay in RomM, but it has to register or pair again to sync.", + "device-delete-failed": "Could not remove the device: {detail}", + "device-deleted": "Device removed", + "device-last-seen": "Last seen", + "device-rename": "Rename device", + "device-sync": "Sync", + "device-this-browser": "This browser", + "device-unnamed": "Unnamed device", + "device-update-failed": "Could not update the device: {detail}", + "devices": "Devices", + "devices-description": "Apps and browsers that sync saves with your account. A browser joins this list the first time it plays a game.", + "devices-empty": "No devices yet.", "disable-animations": "Disable animations", "disable-animations-desc": "Disable all animations in the gallery (CD spinning, cartridge loading, hover videos)", "disable-user": "Disable {username}", diff --git a/frontend/src/locales/en_US/play.json b/frontend/src/locales/en_US/play.json index d3b88f750..3717ac22f 100644 --- a/frontend/src/locales/en_US/play.json +++ b/frontend/src/locales/en_US/play.json @@ -43,7 +43,7 @@ "desktop-title": "Container desktop", "discs": "Discs", "download-memory-card": "Download card", - "easyrpg-browser-save-warning": "EasyRPG saves are stored only in this browser and are not synced with RomM.", + "easyrpg-saves-load-failed": "Could not load your saves from RomM.", "easyrpg-screen": "EasyRPG game screen", "edit-memory-card": "Edit memory card", "emulator": "Emulator", @@ -65,7 +65,6 @@ "import-refusals-truncated": "{count} more issue not shown | {count} more issues not shown", "join-closed": "That session is no longer open to other players.", "join-ended": "That session has ended.", - "jsdos-browser-save-warning": "js-dos saves are stored only in this browser and are not synced with RomM.", "jsdos-quit-without-saving": "Quit without saving recent progress?", "keep-playing": "Keep playing", "last-save-synced": "Last save synced for {game}.", @@ -174,6 +173,7 @@ "powered-by": "Powered by", "quick-state-loaded": "Quick state loaded", "quit": "Quit", + "quit-before-save-synced": "Quit before your latest save reaches RomM?", "restart": "Restart", "resume": "Resume", "resume-failed": "Could not load the selected state. The game started fresh.", diff --git a/frontend/src/locales/en_US/settings.json b/frontend/src/locales/en_US/settings.json index d73a770bc..cbe02145e 100644 --- a/frontend/src/locales/en_US/settings.json +++ b/frontend/src/locales/en_US/settings.json @@ -151,6 +151,18 @@ "device-auth-no-code": "No pairing code provided.", "device-auth-scopes-label": "This device is requesting:", "device-auth-subheading": "A device is requesting access to your RomM library.", + "device-confirm-delete": "Remove {name}? Its saves stay in RomM, but it has to register or pair again to sync.", + "device-delete-failed": "Could not remove the device: {detail}", + "device-deleted": "Device removed", + "device-last-seen": "Last seen", + "device-rename": "Rename device", + "device-sync": "Sync", + "device-this-browser": "This browser", + "device-unnamed": "Unnamed device", + "device-update-failed": "Could not update the device: {detail}", + "devices": "Devices", + "devices-description": "Apps and browsers that sync saves with your account. A browser joins this list the first time it plays a game.", + "devices-empty": "No devices yet.", "disable-animations": "Disable animations", "disable-animations-desc": "Disable all animations in the gallery (CD spinning, cartridge loading, hover videos)", "disable-user": "Disable {username}", diff --git a/frontend/src/locales/es_ES/play.json b/frontend/src/locales/es_ES/play.json index 10c009f8f..e9f1e638a 100644 --- a/frontend/src/locales/es_ES/play.json +++ b/frontend/src/locales/es_ES/play.json @@ -43,7 +43,7 @@ "desktop-title": "Container desktop", "discs": "Discos", "download-memory-card": "Descargar tarjeta", - "easyrpg-browser-save-warning": "Las partidas de EasyRPG se almacenan solo en este navegador y no se sincronizan con RomM.", + "easyrpg-saves-load-failed": "No se pudieron cargar tus partidas desde RomM.", "easyrpg-screen": "Pantalla de juego de EasyRPG", "edit-memory-card": "Editar tarjeta de memoria", "emulator": "Emulador", @@ -65,7 +65,6 @@ "import-refusals-truncated": "{count} problema más no mostrado | {count} problemas más no mostrados", "join-closed": "Esa sesión ya no está abierta a otros jugadores.", "join-ended": "Esa sesión ha terminado.", - "jsdos-browser-save-warning": "Las partidas de js-dos se almacenan solo en este navegador y no se sincronizan con RomM.", "jsdos-quit-without-saving": "¿Salir sin guardar el progreso reciente?", "keep-playing": "Seguir jugando", "last-save-synced": "Se ha sincronizado la última partida guardada de {game}.", @@ -174,6 +173,7 @@ "powered-by": "Con la tecnología de", "quick-state-loaded": "Estado rápido cargado", "quit": "Salir", + "quit-before-save-synced": "¿Salir antes de que tu última partida llegue a RomM?", "restart": "Reiniciar", "resume": "Reanudar", "resume-failed": "No se pudo cargar el estado seleccionado. La partida comenzó desde cero.", diff --git a/frontend/src/locales/es_ES/settings.json b/frontend/src/locales/es_ES/settings.json index a084b53c4..136511619 100644 --- a/frontend/src/locales/es_ES/settings.json +++ b/frontend/src/locales/es_ES/settings.json @@ -151,6 +151,18 @@ "device-auth-no-code": "No pairing code provided.", "device-auth-scopes-label": "This device is requesting:", "device-auth-subheading": "A device is requesting access to your RomM library.", + "device-confirm-delete": "¿Quitar {name}? Sus partidas se quedan en RomM, pero tendrá que registrarse o vincularse de nuevo para sincronizar.", + "device-delete-failed": "No se pudo quitar el dispositivo: {detail}", + "device-deleted": "Dispositivo quitado", + "device-last-seen": "Visto por última vez", + "device-rename": "Renombrar dispositivo", + "device-sync": "Sincronizar", + "device-this-browser": "Este navegador", + "device-unnamed": "Dispositivo sin nombre", + "device-update-failed": "No se pudo actualizar el dispositivo: {detail}", + "devices": "Dispositivos", + "devices-description": "Aplicaciones y navegadores que sincronizan partidas con tu cuenta. Un navegador aparece aquí la primera vez que juega a un juego.", + "devices-empty": "Aún no hay dispositivos.", "disable-animations": "Desactivar animaciones", "disable-animations-desc": "Desactivar todas las animaciones en la galería (rotación de CD, carga de cartuchos, videos al pasar el ratón)", "disable-user": "Deshabilitar usuario", diff --git a/frontend/src/locales/fr_FR/play.json b/frontend/src/locales/fr_FR/play.json index 8bde94321..90e927c1c 100644 --- a/frontend/src/locales/fr_FR/play.json +++ b/frontend/src/locales/fr_FR/play.json @@ -43,7 +43,7 @@ "desktop-title": "Container desktop", "discs": "Disques", "download-memory-card": "Télécharger la carte", - "easyrpg-browser-save-warning": "Les sauvegardes EasyRPG sont stockées uniquement dans ce navigateur et ne sont pas synchronisées avec RomM.", + "easyrpg-saves-load-failed": "Impossible de charger vos sauvegardes depuis RomM.", "easyrpg-screen": "Écran de jeu EasyRPG", "edit-memory-card": "Modifier la carte mémoire", "emulator": "Émulateur", @@ -65,7 +65,6 @@ "import-refusals-truncated": "{count} problème supplémentaire non affiché | {count} problèmes supplémentaires non affichés", "join-closed": "Cette session n'est plus ouverte aux autres joueurs.", "join-ended": "Cette session est terminée.", - "jsdos-browser-save-warning": "Les sauvegardes js-dos sont stockées uniquement dans ce navigateur et ne sont pas synchronisées avec RomM.", "jsdos-quit-without-saving": "Quitter sans enregistrer la progression récente ?", "keep-playing": "Continuer à jouer", "last-save-synced": "Dernière sauvegarde de {game} synchronisée.", @@ -174,6 +173,7 @@ "powered-by": "Propulsé par", "quick-state-loaded": "État rapide chargé", "quit": "Quitter", + "quit-before-save-synced": "Quitter avant que votre dernière sauvegarde n'atteigne RomM ?", "restart": "Redémarrer", "resume": "Reprendre", "resume-failed": "Impossible de charger l'état sélectionné. La partie a démarré de zéro.", diff --git a/frontend/src/locales/fr_FR/settings.json b/frontend/src/locales/fr_FR/settings.json index fd1074765..21c4bbc20 100644 --- a/frontend/src/locales/fr_FR/settings.json +++ b/frontend/src/locales/fr_FR/settings.json @@ -151,6 +151,18 @@ "device-auth-no-code": "No pairing code provided.", "device-auth-scopes-label": "This device is requesting:", "device-auth-subheading": "A device is requesting access to your RomM library.", + "device-confirm-delete": "Retirer {name} ? Ses sauvegardes restent dans RomM, mais il devra s'enregistrer ou s'appairer à nouveau pour synchroniser.", + "device-delete-failed": "Impossible de retirer l'appareil : {detail}", + "device-deleted": "Appareil retiré", + "device-last-seen": "Vu pour la dernière fois", + "device-rename": "Renommer l'appareil", + "device-sync": "Synchroniser", + "device-this-browser": "Ce navigateur", + "device-unnamed": "Appareil sans nom", + "device-update-failed": "Impossible de mettre à jour l'appareil : {detail}", + "devices": "Appareils", + "devices-description": "Applications et navigateurs qui synchronisent les sauvegardes avec votre compte. Un navigateur apparaît ici la première fois qu'il lance un jeu.", + "devices-empty": "Aucun appareil pour l'instant.", "disable-animations": "Désactiver les animations", "disable-animations-desc": "Désactiver toutes les animations dans la galerie (rotation de CD, chargement de cartouche, vidéos au survol)", "disable-user": "Désactiver {username}", diff --git a/frontend/src/locales/hu_HU/play.json b/frontend/src/locales/hu_HU/play.json index 84b9b7320..af614b4b7 100644 --- a/frontend/src/locales/hu_HU/play.json +++ b/frontend/src/locales/hu_HU/play.json @@ -43,7 +43,7 @@ "desktop-title": "Container desktop", "discs": "Lemezek", "download-memory-card": "Kártya letöltése", - "easyrpg-browser-save-warning": "Az EasyRPG mentések csak ebben a böngészőben tárolódnak, és nem szinkronizálódnak a RomM-mal.", + "easyrpg-saves-load-failed": "A mentéseket nem sikerült betölteni a RomM-ból.", "easyrpg-screen": "EasyRPG játékképernyő", "edit-memory-card": "Memóriakártya szerkesztése", "emulator": "Emulátor", @@ -65,7 +65,6 @@ "import-refusals-truncated": "{count} további probléma nincs megjelenítve | {count} további probléma nincs megjelenítve", "join-closed": "Ez a munkamenet már nem áll nyitva más játékosok előtt.", "join-ended": "Ez a munkamenet véget ért.", - "jsdos-browser-save-warning": "A js-dos mentések csak ebben a böngészőben tárolódnak, és nem szinkronizálódnak a RomM-mal.", "jsdos-quit-without-saving": "Kilépés a legutóbbi előrehaladás mentése nélkül?", "keep-playing": "Játék folytatása", "last-save-synced": "A(z) {game} legutóbbi mentése szinkronizálva.", @@ -174,6 +173,7 @@ "powered-by": "Üzemeltető:", "quick-state-loaded": "Gyorsállás betöltve", "quit": "Kilépés", + "quit-before-save-synced": "Kilépés, mielőtt a legutóbbi mentés eljut a RomM-ba?", "restart": "Újraindítás", "resume": "Folytatás", "resume-failed": "A kiválasztott állapot betöltése nem sikerült. A játék tiszta lappal indult.", diff --git a/frontend/src/locales/hu_HU/settings.json b/frontend/src/locales/hu_HU/settings.json index 85c5593b8..e004bec2c 100644 --- a/frontend/src/locales/hu_HU/settings.json +++ b/frontend/src/locales/hu_HU/settings.json @@ -151,6 +151,18 @@ "device-auth-no-code": "No pairing code provided.", "device-auth-scopes-label": "This device is requesting:", "device-auth-subheading": "A device is requesting access to your RomM library.", + "device-confirm-delete": "Eltávolítod: {name}? A mentései a RomM-ban maradnak, de a szinkronizáláshoz újra regisztrálnia vagy párosítania kell.", + "device-delete-failed": "Az eszközt nem sikerült eltávolítani: {detail}", + "device-deleted": "Eszköz eltávolítva", + "device-last-seen": "Utoljára látva", + "device-rename": "Eszköz átnevezése", + "device-sync": "Szinkronizálás", + "device-this-browser": "Ez a böngésző", + "device-unnamed": "Névtelen eszköz", + "device-update-failed": "Az eszközt nem sikerült frissíteni: {detail}", + "devices": "Eszközök", + "devices-description": "Alkalmazások és böngészők, amelyek a fiókoddal szinkronizálják a mentéseket. Egy böngésző akkor jelenik meg itt, amikor először elindít egy játékot.", + "devices-empty": "Még nincsenek eszközök.", "disable-animations": "Animációk letiltása", "disable-animations-desc": "Kapcsolja ki az összes animációt a galériában (CD forgatás, kazetta betöltés, lebegő videók)", "disable-user": "{username} letiltása", diff --git a/frontend/src/locales/it_IT/play.json b/frontend/src/locales/it_IT/play.json index a0f92c5cf..4453dff71 100644 --- a/frontend/src/locales/it_IT/play.json +++ b/frontend/src/locales/it_IT/play.json @@ -43,7 +43,7 @@ "desktop-title": "Container desktop", "discs": "Dischi", "download-memory-card": "Scarica card", - "easyrpg-browser-save-warning": "I salvataggi di EasyRPG vengono archiviati solo in questo browser e non vengono sincronizzati con RomM.", + "easyrpg-saves-load-failed": "Impossibile caricare i salvataggi da RomM.", "easyrpg-screen": "Schermo di gioco EasyRPG", "edit-memory-card": "Modifica memory card", "emulator": "Emulatore", @@ -65,7 +65,6 @@ "import-refusals-truncated": "{count} problema in più non mostrato | {count} problemi in più non mostrati", "join-closed": "Quella sessione non è più aperta ad altri giocatori.", "join-ended": "Quella sessione è terminata.", - "jsdos-browser-save-warning": "I salvataggi di js-dos vengono archiviati solo in questo browser e non vengono sincronizzati con RomM.", "jsdos-quit-without-saving": "Uscire senza salvare i progressi recenti?", "keep-playing": "Continua a giocare", "last-save-synced": "Ultimo salvataggio di {game} sincronizzato.", @@ -174,6 +173,7 @@ "powered-by": "Powered by", "quick-state-loaded": "Stato rapido caricato", "quit": "Esci", + "quit-before-save-synced": "Uscire prima che l'ultimo salvataggio raggiunga RomM?", "restart": "Riavvia", "resume": "Riprendi", "resume-failed": "Impossibile caricare lo stato selezionato. La partita è iniziata da zero.", diff --git a/frontend/src/locales/it_IT/settings.json b/frontend/src/locales/it_IT/settings.json index 48dce8f68..0945b86a5 100644 --- a/frontend/src/locales/it_IT/settings.json +++ b/frontend/src/locales/it_IT/settings.json @@ -151,6 +151,18 @@ "device-auth-no-code": "Non è stato fornito alcun codice di associazione.", "device-auth-scopes-label": "Questo dispositivo sta richiedendo:", "device-auth-subheading": "Un dispositivo sta richiedendo l'accesso alla tua libreria RomM.", + "device-confirm-delete": "Rimuovere {name}? I suoi salvataggi restano in RomM, ma dovrà registrarsi o associarsi di nuovo per sincronizzare.", + "device-delete-failed": "Impossibile rimuovere il dispositivo: {detail}", + "device-deleted": "Dispositivo rimosso", + "device-last-seen": "Visto l'ultima volta", + "device-rename": "Rinomina dispositivo", + "device-sync": "Sincronizza", + "device-this-browser": "Questo browser", + "device-unnamed": "Dispositivo senza nome", + "device-update-failed": "Impossibile aggiornare il dispositivo: {detail}", + "devices": "Dispositivi", + "devices-description": "App e browser che sincronizzano i salvataggi con il tuo account. Un browser compare qui la prima volta che avvia un gioco.", + "devices-empty": "Ancora nessun dispositivo.", "disable-animations": "Disabilita animazioni", "disable-animations-desc": "Disabilita tutte le animazioni nella galleria (rotazione CD, caricamento cartucce, video al passaggio del mouse)", "disable-user": "Disabilita {username}", diff --git a/frontend/src/locales/ja_JP/play.json b/frontend/src/locales/ja_JP/play.json index 30e801413..42fe057e5 100644 --- a/frontend/src/locales/ja_JP/play.json +++ b/frontend/src/locales/ja_JP/play.json @@ -43,7 +43,7 @@ "desktop-title": "Container desktop", "discs": "ディスク", "download-memory-card": "カードをダウンロード", - "easyrpg-browser-save-warning": "EasyRPG のセーブデータはこのブラウザーにのみ保存され、RomM とは同期されません。", + "easyrpg-saves-load-failed": "RomM からセーブデータをロードできませんでした。", "easyrpg-screen": "EasyRPG のゲーム画面", "edit-memory-card": "メモリーカードを編集", "emulator": "エミュレーター", @@ -65,7 +65,6 @@ "import-refusals-truncated": "他に{count}件の問題は表示されていません | 他に{count}件の問題は表示されていません", "join-closed": "このセッションは他のプレイヤーに開放されていません。", "join-ended": "このセッションは終了しました。", - "jsdos-browser-save-warning": "js-dos のセーブデータはこのブラウザーにのみ保存され、RomM とは同期されません。", "jsdos-quit-without-saving": "最近の進行状況を保存せずに終了しますか?", "keep-playing": "プレイを続ける", "last-save-synced": "{game} の最後のセーブデータを同期しました。", @@ -174,6 +173,7 @@ "powered-by": "提供:", "quick-state-loaded": "クイックステートをロードしました", "quit": "終了", + "quit-before-save-synced": "最新のセーブデータが RomM に届く前に終了しますか?", "restart": "リスタート", "resume": "再開", "resume-failed": "選択したステートを読み込めませんでした。ゲームは最初から開始されました。", diff --git a/frontend/src/locales/ja_JP/settings.json b/frontend/src/locales/ja_JP/settings.json index 65e7a1f25..1881c34cc 100644 --- a/frontend/src/locales/ja_JP/settings.json +++ b/frontend/src/locales/ja_JP/settings.json @@ -151,6 +151,18 @@ "device-auth-no-code": "No pairing code provided.", "device-auth-scopes-label": "This device is requesting:", "device-auth-subheading": "A device is requesting access to your RomM library.", + "device-confirm-delete": "{name} を削除しますか?セーブデータは RomM に残りますが、同期するには再度登録またはペアリングが必要です。", + "device-delete-failed": "デバイスを削除できませんでした: {detail}", + "device-deleted": "デバイスを削除しました", + "device-last-seen": "最終確認", + "device-rename": "デバイス名を変更", + "device-sync": "同期", + "device-this-browser": "このブラウザー", + "device-unnamed": "名前のないデバイス", + "device-update-failed": "デバイスを更新できませんでした: {detail}", + "devices": "デバイス", + "devices-description": "アカウントとセーブデータを同期するアプリとブラウザーです。ブラウザーは初めてゲームをプレイしたときにここに表示されます。", + "devices-empty": "デバイスはまだありません。", "disable-animations": "アニメーションを無効化", "disable-animations-desc": "ギャラリー内のすべてのアニメーションを無効化(CD回転、カートリッジ読み込み、ホバービデオ)", "disable-user": "{username}を無効化", diff --git a/frontend/src/locales/ko_KR/play.json b/frontend/src/locales/ko_KR/play.json index 430b04528..b9b835886 100644 --- a/frontend/src/locales/ko_KR/play.json +++ b/frontend/src/locales/ko_KR/play.json @@ -43,7 +43,7 @@ "desktop-title": "Container desktop", "discs": "디스크", "download-memory-card": "카드 다운로드", - "easyrpg-browser-save-warning": "EasyRPG 저장 데이터는 이 브라우저에만 저장되며 RomM과 동기화되지 않습니다.", + "easyrpg-saves-load-failed": "RomM에서 세이브를 불러오지 못했습니다.", "easyrpg-screen": "EasyRPG 게임 화면", "edit-memory-card": "메모리 카드 편집", "emulator": "에뮬레이터", @@ -65,7 +65,6 @@ "import-refusals-truncated": "표시되지 않은 문제가 {count}개 더 있습니다 | 표시되지 않은 문제가 {count}개 더 있습니다", "join-closed": "이 세션은 더 이상 다른 플레이어에게 열려 있지 않습니다.", "join-ended": "이 세션은 종료되었습니다.", - "jsdos-browser-save-warning": "js-dos 저장 데이터는 이 브라우저에만 저장되며 RomM과 동기화되지 않습니다.", "jsdos-quit-without-saving": "최근 진행 상황을 저장하지 않고 종료하시겠습니까?", "keep-playing": "계속 플레이", "last-save-synced": "{game}의 마지막 저장 데이터를 동기화했습니다.", @@ -174,6 +173,7 @@ "powered-by": "제공", "quick-state-loaded": "빠른 상태를 불러왔습니다", "quit": "종료", + "quit-before-save-synced": "최신 세이브가 RomM에 전송되기 전에 종료하시겠습니까?", "restart": "다시 시작", "resume": "계속", "resume-failed": "선택한 상태를 불러오지 못했습니다. 게임이 처음부터 시작되었습니다.", diff --git a/frontend/src/locales/ko_KR/settings.json b/frontend/src/locales/ko_KR/settings.json index a6cbe8228..91f2c5e35 100644 --- a/frontend/src/locales/ko_KR/settings.json +++ b/frontend/src/locales/ko_KR/settings.json @@ -151,6 +151,18 @@ "device-auth-no-code": "No pairing code provided.", "device-auth-scopes-label": "This device is requesting:", "device-auth-subheading": "A device is requesting access to your RomM library.", + "device-confirm-delete": "{name}을(를) 제거할까요? 저장 데이터는 RomM에 남지만, 동기화하려면 다시 등록하거나 페어링해야 합니다.", + "device-delete-failed": "기기를 제거할 수 없습니다: {detail}", + "device-deleted": "기기를 제거했습니다", + "device-last-seen": "마지막 접속", + "device-rename": "기기 이름 변경", + "device-sync": "동기화", + "device-this-browser": "이 브라우저", + "device-unnamed": "이름 없는 기기", + "device-update-failed": "기기를 업데이트할 수 없습니다: {detail}", + "devices": "기기", + "devices-description": "계정과 저장 데이터를 동기화하는 앱과 브라우저입니다. 브라우저는 처음 게임을 실행할 때 이 목록에 추가됩니다.", + "devices-empty": "아직 기기가 없습니다.", "disable-animations": "애니메이션 비활성화", "disable-animations-desc": "갤러리의 모든 애니메이션 비활성화 (CD 회전, 카트리지 로딩, 호버 비디오)", "disable-user": "{username} 비활성화", diff --git a/frontend/src/locales/pl_PL/play.json b/frontend/src/locales/pl_PL/play.json index 448cefcdc..02ad0faf2 100644 --- a/frontend/src/locales/pl_PL/play.json +++ b/frontend/src/locales/pl_PL/play.json @@ -43,7 +43,7 @@ "desktop-title": "Container desktop", "discs": "Płyty", "download-memory-card": "Pobierz kartę", - "easyrpg-browser-save-warning": "Zapisy EasyRPG są przechowywane tylko w tej przeglądarce i nie są synchronizowane z RomM.", + "easyrpg-saves-load-failed": "Nie udało się wczytać zapisów z RomM.", "easyrpg-screen": "Ekran gry EasyRPG", "edit-memory-card": "Edytuj kartę pamięci", "emulator": "Emulator", @@ -65,7 +65,6 @@ "import-refusals-truncated": "Nie pokazano kolejnych problemów: {count} | {count} kolejny problem nie został pokazany | Nie pokazano kolejnych problemów: {count}", "join-closed": "Ta sesja nie jest już otwarta dla innych graczy.", "join-ended": "Ta sesja się zakończyła.", - "jsdos-browser-save-warning": "Zapisy js-dos są przechowywane tylko w tej przeglądarce i nie są synchronizowane z RomM.", "jsdos-quit-without-saving": "Wyjść bez zapisywania ostatnich postępów?", "keep-playing": "Graj dalej", "last-save-synced": "Zsynchronizowano ostatni zapis gry {game}.", @@ -174,6 +173,7 @@ "powered-by": "Zasilane przez", "quick-state-loaded": "Wczytano szybki stan", "quit": "Zakończ", + "quit-before-save-synced": "Wyjść, zanim ostatni zapis trafi do RomM?", "restart": "Uruchom ponownie", "resume": "Wznów", "resume-failed": "Nie udało się wczytać wybranego stanu. Gra rozpoczęła się od nowa.", diff --git a/frontend/src/locales/pl_PL/settings.json b/frontend/src/locales/pl_PL/settings.json index a663c1603..2bccbe754 100644 --- a/frontend/src/locales/pl_PL/settings.json +++ b/frontend/src/locales/pl_PL/settings.json @@ -151,6 +151,18 @@ "device-auth-no-code": "No pairing code provided.", "device-auth-scopes-label": "This device is requesting:", "device-auth-subheading": "A device is requesting access to your RomM library.", + "device-confirm-delete": "Usunąć {name}? Zapisy pozostaną w RomM, ale urządzenie będzie musiało ponownie się zarejestrować lub sparować, aby synchronizować.", + "device-delete-failed": "Nie udało się usunąć urządzenia: {detail}", + "device-deleted": "Urządzenie usunięte", + "device-last-seen": "Ostatnio widziane", + "device-rename": "Zmień nazwę urządzenia", + "device-sync": "Synchronizacja", + "device-this-browser": "Ta przeglądarka", + "device-unnamed": "Urządzenie bez nazwy", + "device-update-failed": "Nie udało się zaktualizować urządzenia: {detail}", + "devices": "Urządzenia", + "devices-description": "Aplikacje i przeglądarki, które synchronizują zapisy z Twoim kontem. Przeglądarka pojawia się tutaj, gdy po raz pierwszy uruchomi grę.", + "devices-empty": "Brak urządzeń.", "disable-animations": "Wyłącz animacje", "disable-animations-desc": "Wyłącz wszystkie animacje w galerii (obracanie CD, ładowanie kartridży, wideo po najechaniu)", "disable-user": "Wyłącz {username}", diff --git a/frontend/src/locales/pt_BR/play.json b/frontend/src/locales/pt_BR/play.json index 088966c7b..f7c1a487c 100644 --- a/frontend/src/locales/pt_BR/play.json +++ b/frontend/src/locales/pt_BR/play.json @@ -43,7 +43,7 @@ "desktop-title": "Container desktop", "discs": "Discos", "download-memory-card": "Baixar cartão", - "easyrpg-browser-save-warning": "Os saves do EasyRPG são armazenados apenas neste navegador e não são sincronizados com o RomM.", + "easyrpg-saves-load-failed": "Não foi possível carregar os saves do RomM.", "easyrpg-screen": "Tela de jogo do EasyRPG", "edit-memory-card": "Editar cartão de memória", "emulator": "Emulador", @@ -65,7 +65,6 @@ "import-refusals-truncated": "{count} problema a mais não exibido | {count} problemas a mais não exibidos", "join-closed": "Essa sessão não está mais aberta a outros jogadores.", "join-ended": "Essa sessão foi encerrada.", - "jsdos-browser-save-warning": "Os saves do js-dos são armazenados apenas neste navegador e não são sincronizados com o RomM.", "jsdos-quit-without-saving": "Sair sem salvar o progresso recente?", "keep-playing": "Continuar jogando", "last-save-synced": "Último save de {game} sincronizado.", @@ -174,6 +173,7 @@ "powered-by": "Desenvolvido com", "quick-state-loaded": "State rápido carregado", "quit": "Sair", + "quit-before-save-synced": "Sair antes que seu último save chegue ao RomM?", "restart": "Reiniciar", "resume": "Retomar", "resume-failed": "Não foi possível carregar o estado selecionado. O jogo começou do zero.", diff --git a/frontend/src/locales/pt_BR/settings.json b/frontend/src/locales/pt_BR/settings.json index 61171c0be..073199720 100644 --- a/frontend/src/locales/pt_BR/settings.json +++ b/frontend/src/locales/pt_BR/settings.json @@ -151,6 +151,18 @@ "device-auth-no-code": "Nenhum código de pareamento fornecido.", "device-auth-scopes-label": "Este dispositivo está solicitando:", "device-auth-subheading": "Um dispositivo está solicitando acesso à sua biblioteca do RomM.", + "device-confirm-delete": "Remover {name}? Os saves continuam no RomM, mas ele precisará se registrar ou parear de novo para sincronizar.", + "device-delete-failed": "Não foi possível remover o dispositivo: {detail}", + "device-deleted": "Dispositivo removido", + "device-last-seen": "Visto por último", + "device-rename": "Renomear dispositivo", + "device-sync": "Sincronizar", + "device-this-browser": "Este navegador", + "device-unnamed": "Dispositivo sem nome", + "device-update-failed": "Não foi possível atualizar o dispositivo: {detail}", + "devices": "Dispositivos", + "devices-description": "Aplicativos e navegadores que sincronizam saves com sua conta. Um navegador aparece aqui na primeira vez que joga um jogo.", + "devices-empty": "Nenhum dispositivo ainda.", "disable-animations": "Desativar animações", "disable-animations-desc": "Desativar todas as animações na galeria (rotação de CD, carregamento de cartuchos, vídeos ao passar o mouse)", "disable-user": "Desabilitar {username}", diff --git a/frontend/src/locales/ro_RO/play.json b/frontend/src/locales/ro_RO/play.json index 270e4514f..e13c02dce 100644 --- a/frontend/src/locales/ro_RO/play.json +++ b/frontend/src/locales/ro_RO/play.json @@ -43,7 +43,7 @@ "desktop-title": "Container desktop", "discs": "Discuri", "download-memory-card": "Descarcă cardul", - "easyrpg-browser-save-warning": "Salvările EasyRPG sunt stocate numai în acest browser și nu sunt sincronizate cu RomM.", + "easyrpg-saves-load-failed": "Salvările nu au putut fi încărcate din RomM.", "easyrpg-screen": "Ecran de joc EasyRPG", "edit-memory-card": "Editează cardul de memorie", "emulator": "Emulator", @@ -65,7 +65,6 @@ "import-refusals-truncated": "Încă {count} problemă nu este afișată | Încă {count} probleme nu sunt afișate", "join-closed": "Acea sesiune nu mai este deschisă altor jucători.", "join-ended": "Acea sesiune s-a încheiat.", - "jsdos-browser-save-warning": "Salvările js-dos sunt stocate numai în acest browser și nu sunt sincronizate cu RomM.", "jsdos-quit-without-saving": "Ieși fără a salva progresul recent?", "keep-playing": "Continuă să joci", "last-save-synced": "Ultima salvare pentru {game} a fost sincronizată.", @@ -174,6 +173,7 @@ "powered-by": "Susținut de", "quick-state-loaded": "Stare rapidă încărcată", "quit": "Ieși", + "quit-before-save-synced": "Ieși înainte ca ultima salvare să ajungă în RomM?", "restart": "Repornește", "resume": "Continuă", "resume-failed": "Starea selectată nu a putut fi încărcată. Jocul a pornit de la zero.", diff --git a/frontend/src/locales/ro_RO/settings.json b/frontend/src/locales/ro_RO/settings.json index facaf0ea5..0c0dfe0f8 100644 --- a/frontend/src/locales/ro_RO/settings.json +++ b/frontend/src/locales/ro_RO/settings.json @@ -151,6 +151,18 @@ "device-auth-no-code": "No pairing code provided.", "device-auth-scopes-label": "This device is requesting:", "device-auth-subheading": "A device is requesting access to your RomM library.", + "device-confirm-delete": "Elimini {name}? Salvările rămân în RomM, dar dispozitivul va trebui să se înregistreze sau să se asocieze din nou pentru a sincroniza.", + "device-delete-failed": "Dispozitivul nu a putut fi eliminat: {detail}", + "device-deleted": "Dispozitiv eliminat", + "device-last-seen": "Văzut ultima dată", + "device-rename": "Redenumește dispozitivul", + "device-sync": "Sincronizare", + "device-this-browser": "Acest browser", + "device-unnamed": "Dispozitiv fără nume", + "device-update-failed": "Dispozitivul nu a putut fi actualizat: {detail}", + "devices": "Dispozitive", + "devices-description": "Aplicații și browsere care sincronizează salvările cu contul tău. Un browser apare aici prima dată când pornește un joc.", + "devices-empty": "Încă nu există dispozitive.", "disable-animations": "Dezactivează animațiile", "disable-animations-desc": "Dezactivează toate animațiile din galerie (rotirea CD-ului, încărcarea cartușului, videoclipurile la trecerea mouse-ului)", "disable-user": "Dezactivează {username}", diff --git a/frontend/src/locales/ru_RU/play.json b/frontend/src/locales/ru_RU/play.json index c1494eaf1..0d044258c 100644 --- a/frontend/src/locales/ru_RU/play.json +++ b/frontend/src/locales/ru_RU/play.json @@ -43,7 +43,7 @@ "desktop-title": "Container desktop", "discs": "Диски", "download-memory-card": "Скачать карту", - "easyrpg-browser-save-warning": "Сохранения EasyRPG хранятся только в этом браузере и не синхронизируются с RomM.", + "easyrpg-saves-load-failed": "Не удалось загрузить сохранения из RomM.", "easyrpg-screen": "Игровой экран EasyRPG", "edit-memory-card": "Изменить карту памяти", "emulator": "Эмулятор", @@ -65,7 +65,6 @@ "import-refusals-truncated": "Ещё {count} проблема не показана | Ещё {count} проблем не показано", "join-closed": "Эта сессия больше не открыта для других игроков.", "join-ended": "Эта сессия завершена.", - "jsdos-browser-save-warning": "Сохранения js-dos хранятся только в этом браузере и не синхронизируются с RomM.", "jsdos-quit-without-saving": "Выйти, не сохраняя недавний прогресс?", "keep-playing": "Продолжить игру", "last-save-synced": "Последнее сохранение для {game} синхронизировано.", @@ -174,6 +173,7 @@ "powered-by": "На базе", "quick-state-loaded": "Быстрое состояние загружено", "quit": "Выйти", + "quit-before-save-synced": "Выйти, пока последнее сохранение не отправлено в RomM?", "restart": "Перезапустить", "resume": "Продолжить", "resume-failed": "Не удалось загрузить выбранное состояние. Игра началась заново.", diff --git a/frontend/src/locales/ru_RU/settings.json b/frontend/src/locales/ru_RU/settings.json index 0df080b1c..12e3b4f60 100644 --- a/frontend/src/locales/ru_RU/settings.json +++ b/frontend/src/locales/ru_RU/settings.json @@ -151,6 +151,18 @@ "device-auth-no-code": "No pairing code provided.", "device-auth-scopes-label": "This device is requesting:", "device-auth-subheading": "A device is requesting access to your RomM library.", + "device-confirm-delete": "Удалить {name}? Сохранения останутся в RomM, но для синхронизации устройству придётся снова зарегистрироваться или выполнить сопряжение.", + "device-delete-failed": "Не удалось удалить устройство: {detail}", + "device-deleted": "Устройство удалено", + "device-last-seen": "Последняя активность", + "device-rename": "Переименовать устройство", + "device-sync": "Синхронизация", + "device-this-browser": "Этот браузер", + "device-unnamed": "Устройство без имени", + "device-update-failed": "Не удалось обновить устройство: {detail}", + "devices": "Устройства", + "devices-description": "Приложения и браузеры, которые синхронизируют сохранения с вашей учётной записью. Браузер появляется здесь, когда впервые запускает игру.", + "devices-empty": "Устройств пока нет.", "disable-animations": "Отключить анимации", "disable-animations-desc": "Отключить все анимации в галерее (вращение CD, загрузка картриджей, видео при наведении)", "disable-user": "Отключить {username}", diff --git a/frontend/src/locales/tr_TR/play.json b/frontend/src/locales/tr_TR/play.json index 2ddeb928d..957fbfc12 100644 --- a/frontend/src/locales/tr_TR/play.json +++ b/frontend/src/locales/tr_TR/play.json @@ -43,7 +43,7 @@ "desktop-title": "Container desktop", "discs": "Diskler", "download-memory-card": "Kartı indir", - "easyrpg-browser-save-warning": "EasyRPG kayıtları yalnızca bu tarayıcıda saklanır ve RomM ile eşitlenmez.", + "easyrpg-saves-load-failed": "Kayıtlar RomM'dan yüklenemedi.", "easyrpg-screen": "EasyRPG oyun ekranı", "edit-memory-card": "Hafıza kartını düzenle", "emulator": "Emülatör", @@ -65,7 +65,6 @@ "import-refusals-truncated": "{count} sorun daha gösterilmedi | {count} sorun daha gösterilmedi", "join-closed": "Bu oturum artık diğer oyunculara açık değil.", "join-ended": "Bu oturum sona erdi.", - "jsdos-browser-save-warning": "js-dos kayıtları yalnızca bu tarayıcıda saklanır ve RomM ile eşitlenmez.", "jsdos-quit-without-saving": "Son ilerlemeyi kaydetmeden çıkılsın mı?", "keep-playing": "Oynamaya devam et", "last-save-synced": "{game} için son kayıt eşitlendi.", @@ -174,6 +173,7 @@ "powered-by": "Tarafından desteklenmektedir", "quick-state-loaded": "Hızlı durum kaydı yüklendi", "quit": "Çık", + "quit-before-save-synced": "Son kaydınız RomM'a ulaşmadan çıkılsın mı?", "restart": "Yeniden başlat", "resume": "Devam et", "resume-failed": "Seçilen durum yüklenemedi. Oyun sıfırdan başladı.", diff --git a/frontend/src/locales/tr_TR/settings.json b/frontend/src/locales/tr_TR/settings.json index 55c24b77e..de497b9df 100644 --- a/frontend/src/locales/tr_TR/settings.json +++ b/frontend/src/locales/tr_TR/settings.json @@ -151,6 +151,18 @@ "device-auth-no-code": "Eşleştirme kodu sağlanmadı.", "device-auth-scopes-label": "Bu cihaz şunları istiyor:", "device-auth-subheading": "Bir cihaz RomM kütüphanenize erişim istiyor.", + "device-confirm-delete": "{name} kaldırılsın mı? Kayıtları RomM'da kalır, ancak eşitlemek için yeniden kaydolması veya eşleşmesi gerekir.", + "device-delete-failed": "Cihaz kaldırılamadı: {detail}", + "device-deleted": "Cihaz kaldırıldı", + "device-last-seen": "Son görülme", + "device-rename": "Cihazı yeniden adlandır", + "device-sync": "Eşitleme", + "device-this-browser": "Bu tarayıcı", + "device-unnamed": "Adsız cihaz", + "device-update-failed": "Cihaz güncellenemedi: {detail}", + "devices": "Cihazlar", + "devices-description": "Hesabınızla kayıtları eşitleyen uygulamalar ve tarayıcılar. Bir tarayıcı ilk kez oyun başlattığında burada görünür.", + "devices-empty": "Henüz cihaz yok.", "disable-animations": "Animasyonları devre dışı bırak", "disable-animations-desc": "Galerideki tüm animasyonları devre dışı bırak (CD dönmesi, kartuş yüklenmesi, üzerine gelince video)", "disable-user": "{username} kullanıcısını devre dışı bırak", diff --git a/frontend/src/locales/zh_CN/play.json b/frontend/src/locales/zh_CN/play.json index 2bf2a2d1f..cf290be5d 100644 --- a/frontend/src/locales/zh_CN/play.json +++ b/frontend/src/locales/zh_CN/play.json @@ -43,7 +43,7 @@ "desktop-title": "容器桌面", "discs": "光盘", "download-memory-card": "下载记忆卡", - "easyrpg-browser-save-warning": "EasyRPG 存档仅保存在此浏览器中,不会与 RomM 同步。", + "easyrpg-saves-load-failed": "无法从 RomM 加载存档。", "easyrpg-screen": "EasyRPG 游戏画面", "edit-memory-card": "编辑记忆卡", "emulator": "模拟器", @@ -65,7 +65,6 @@ "import-refusals-truncated": "还有 {count} 个问题未显示 | 还有 {count} 个问题未显示", "join-closed": "该会话不再对其他玩家开放。", "join-ended": "该会话已结束。", - "jsdos-browser-save-warning": "js-dos 存档仅保存在此浏览器中,不会与 RomM 同步。", "jsdos-quit-without-saving": "不保存最近的进度并退出吗?", "keep-playing": "继续游戏", "last-save-synced": "{game} 的最近存档已同步。", @@ -174,6 +173,7 @@ "powered-by": "技术支持", "quick-state-loaded": "已加载快速即时存档", "quit": "退出", + "quit-before-save-synced": "最新存档尚未同步到 RomM,仍要退出吗?", "restart": "重新启动", "resume": "继续", "resume-failed": "无法加载所选的存档状态。游戏已重新开始。", diff --git a/frontend/src/locales/zh_CN/settings.json b/frontend/src/locales/zh_CN/settings.json index 815a95ee8..796f1ba6e 100644 --- a/frontend/src/locales/zh_CN/settings.json +++ b/frontend/src/locales/zh_CN/settings.json @@ -151,6 +151,18 @@ "device-auth-no-code": "未提供配对代码。", "device-auth-scopes-label": "此设备请求以下权限:", "device-auth-subheading": "有设备正在请求访问您的 RomM 游戏库。", + "device-confirm-delete": "移除 {name}?其存档仍保留在 RomM 中,但需要重新注册或配对才能同步。", + "device-delete-failed": "无法移除设备:{detail}", + "device-deleted": "设备已移除", + "device-last-seen": "上次在线", + "device-rename": "重命名设备", + "device-sync": "同步", + "device-this-browser": "此浏览器", + "device-unnamed": "未命名设备", + "device-update-failed": "无法更新设备:{detail}", + "devices": "设备", + "devices-description": "与你的账户同步存档的应用和浏览器。浏览器首次运行游戏时会出现在此列表中。", + "devices-empty": "暂无设备。", "disable-animations": "禁用动画", "disable-animations-desc": "禁用游戏库中的所有动画(CD 旋转、卡带加载、悬停视频)", "disable-user": "禁用 {username}", diff --git a/frontend/src/locales/zh_TW/play.json b/frontend/src/locales/zh_TW/play.json index a4bd97656..c91ad8668 100644 --- a/frontend/src/locales/zh_TW/play.json +++ b/frontend/src/locales/zh_TW/play.json @@ -43,7 +43,7 @@ "desktop-title": "Container desktop", "discs": "光碟", "download-memory-card": "下載記憶卡", - "easyrpg-browser-save-warning": "EasyRPG 存檔僅保存在此瀏覽器中,不會與 RomM 同步。", + "easyrpg-saves-load-failed": "無法從 RomM 載入存檔。", "easyrpg-screen": "EasyRPG 遊戲畫面", "edit-memory-card": "編輯記憶卡", "emulator": "模擬器", @@ -65,7 +65,6 @@ "import-refusals-truncated": "還有 {count} 個問題未顯示 | 還有 {count} 個問題未顯示", "join-closed": "該工作階段不再對其他玩家開放。", "join-ended": "該工作階段已結束。", - "jsdos-browser-save-warning": "js-dos 存檔僅保存在此瀏覽器中,不會與 RomM 同步。", "jsdos-quit-without-saving": "不儲存最近的進度並退出嗎?", "keep-playing": "繼續遊戲", "last-save-synced": "{game} 的最近存檔已同步。", @@ -174,6 +173,7 @@ "powered-by": "技術提供", "quick-state-loaded": "已載入快速即時存檔", "quit": "退出", + "quit-before-save-synced": "最新存檔尚未同步到 RomM,仍要退出嗎?", "restart": "重新啟動", "resume": "繼續", "resume-failed": "無法載入所選的存檔狀態。遊戲已重新開始。", diff --git a/frontend/src/locales/zh_TW/settings.json b/frontend/src/locales/zh_TW/settings.json index 951188c18..18ef4cfa3 100644 --- a/frontend/src/locales/zh_TW/settings.json +++ b/frontend/src/locales/zh_TW/settings.json @@ -151,6 +151,18 @@ "device-auth-no-code": "No pairing code provided.", "device-auth-scopes-label": "This device is requesting:", "device-auth-subheading": "A device is requesting access to your RomM library.", + "device-confirm-delete": "移除 {name}?其存檔仍保留在 RomM 中,但需要重新註冊或配對才能同步。", + "device-delete-failed": "無法移除裝置:{detail}", + "device-deleted": "裝置已移除", + "device-last-seen": "上次上線", + "device-rename": "重新命名裝置", + "device-sync": "同步", + "device-this-browser": "此瀏覽器", + "device-unnamed": "未命名裝置", + "device-update-failed": "無法更新裝置:{detail}", + "devices": "裝置", + "devices-description": "與你的帳戶同步存檔的應用程式和瀏覽器。瀏覽器首次執行遊戲時會出現在此清單中。", + "devices-empty": "尚無裝置。", "disable-animations": "停用動畫", "disable-animations-desc": "停用遊戲庫中的所有動畫(CD 旋轉、卡帶載入、懸停影片)", "disable-user": "停用 {username}", diff --git a/frontend/src/plugins/routeNames.ts b/frontend/src/plugins/routeNames.ts index f50b63dec..5731587e0 100644 --- a/frontend/src/plugins/routeNames.ts +++ b/frontend/src/plugins/routeNames.ts @@ -30,6 +30,7 @@ export const ROUTES = { CONVERSION_SETTINGS: "conversion-settings", METADATA_SOURCES: "metadata-sources", CLIENT_API_TOKENS: "client-api-tokens", + DEVICES: "devices", ADMINISTRATION: "administration", SERVER_STATS: "server-stats", LOGS: "logs", diff --git a/frontend/src/plugins/router.ts b/frontend/src/plugins/router.ts index de58bafcd..f1e52e61b 100644 --- a/frontend/src/plugins/router.ts +++ b/frontend/src/plugins/router.ts @@ -438,6 +438,18 @@ const routes = [ v2: v2For(ROUTES.CLIENT_API_TOKENS), }, }, + { + path: "devices", + name: ROUTES.DEVICES, + meta: { + title: "settings.devices", + bare: true, + }, + components: { + default: () => import("@/views/Home.vue"), + v2: v2For(ROUTES.DEVICES), + }, + }, { path: "administration", name: ROUTES.ADMINISTRATION, @@ -609,6 +621,7 @@ const router = createRouter({ const routePermissions: RoutePermissions[] = [ { path: ROUTES.CLIENT_API_TOKENS, requiredScopes: ["me.write"] }, + { path: ROUTES.DEVICES, requiredScopes: ["devices.read"] }, { path: ROUTES.SCAN, requiredScopes: ["platforms.write"] }, { path: ROUTES.UPLOAD, requiredScopes: ["roms.write"] }, { path: ROUTES.LIBRARY_MANAGEMENT, requiredScopes: ["platforms.write"] }, diff --git a/frontend/src/services/api/device.ts b/frontend/src/services/api/device.ts index 45d558852..69a23b568 100644 --- a/frontend/src/services/api/device.ts +++ b/frontend/src/services/api/device.ts @@ -1,4 +1,4 @@ -import type { DeviceSchema } from "@/__generated__"; +import type { DeviceSchema, DeviceUpdatePayload } from "@/__generated__"; import api from "@/services/api"; async function fetchDevices() { @@ -9,7 +9,17 @@ async function fetchOnlineDeviceIds() { return api.get("/devices/online"); } +async function updateDevice(id: string, payload: DeviceUpdatePayload) { + return api.put(`/devices/${encodeURIComponent(id)}`, payload); +} + +async function deleteDevice(id: string) { + return api.delete(`/devices/${encodeURIComponent(id)}`); +} + export default { fetchDevices, fetchOnlineDeviceIds, + updateDevice, + deleteDevice, }; diff --git a/frontend/src/services/api/save.test.ts b/frontend/src/services/api/save.test.ts index 22785f656..3a0cf4975 100644 --- a/frontend/src/services/api/save.test.ts +++ b/frontend/src/services/api/save.test.ts @@ -60,6 +60,22 @@ describe("sendSaveOnUnload", () => { expect((init?.body as FormData).get("saveFile")).toBeInstanceOf(File); }); + it("keeps the stale-device guard when the caller asks for it", () => { + saveApi.sendSaveOnUnload({ + rom, + save: null, + saveFile: saveOf(16), + deviceId: "dev", + slot: "Save01", + overwrite: false, + }); + + const [url] = fetchMock.mock.calls[0]!; + expect(url).toBe( + "/api/saves?rom_id=1&device_id=dev&slot=Save01&overwrite=false", + ); + }); + it("updates the session's version in place", () => { saveApi.sendSaveOnUnload({ rom, diff --git a/frontend/src/services/api/save.ts b/frontend/src/services/api/save.ts index f6a3a6bae..12da1f862 100644 --- a/frontend/src/services/api/save.ts +++ b/frontend/src/services/api/save.ts @@ -68,6 +68,10 @@ interface SaveVersionParams { autocleanup?: boolean | undefined; /** Skip the stale-device conflict check and the content-hash dedupe. */ overwrite?: boolean | undefined; + /** The device's own hash of the file, kept as its sync baseline. */ + contentHash?: string | undefined; + /** The sync session the upload carries out. */ + sessionId?: number | undefined; } function saveVersionQuery({ @@ -77,6 +81,8 @@ function saveVersionQuery({ slot, autocleanup, overwrite, + contentHash, + sessionId, }: SaveVersionParams) { return { rom_id: rom.id, @@ -85,6 +91,8 @@ function saveVersionQuery({ slot, autocleanup, overwrite, + content_hash: contentHash, + session_id: sessionId, }; } @@ -123,11 +131,14 @@ async function updateSave({ saveFile, screenshotFile, deviceId, + contentHash, }: { save: SaveSchema; saveFile: UpdateSaveUploadInput["saveFile"]; screenshotFile?: UpdateSaveUploadInput["screenshotFile"]; deviceId?: string | undefined; + /** The device's own hash of the file, kept as its sync baseline. */ + contentHash?: string | undefined; }) { const formData = buildFormInput([ ["saveFile", saveFile], @@ -135,7 +146,7 @@ async function updateSave({ ]); return api.put(`/saves/${save.id}`, formData, { - params: { device_id: deviceId }, + params: { device_id: deviceId, content_hash: contentHash }, }); } @@ -156,12 +167,15 @@ function sendSaveOnUnload({ ? { url: `/saves/${save.id}`, method: "PUT", - params: { device_id: version.deviceId }, + params: { + device_id: version.deviceId, + content_hash: version.contentHash, + }, } : { url: "/saves", method: "POST", - params: saveVersionQuery({ ...version, overwrite: true }), + params: saveVersionQuery({ overwrite: true, ...version }), }; const csrfToken = Cookies.get("romm_csrftoken"); void fetch(api.getUri(request), { diff --git a/frontend/src/services/api/sync.ts b/frontend/src/services/api/sync.ts new file mode 100644 index 000000000..4202c49ee --- /dev/null +++ b/frontend/src/services/api/sync.ts @@ -0,0 +1,83 @@ +import type { + ClientSaveState, + DeviceCreatePayload, + DeviceCreateResponse, + SyncCompletePayload, + SyncNegotiateResponse, +} from "@/__generated__"; +import api from "@/services/api"; + +async function registerDevice(payload: DeviceCreatePayload) { + return api.post("/devices", payload); +} + +async function negotiate({ + deviceId, + romIds, + saves, + restoreUnlisted = false, + emulators, +}: { + deviceId: string; + romIds: number[]; + saves: ClientSaveState[]; + /** Offer server saves the client did not list, even ones it synced before. */ + restoreUnlisted?: boolean; + /** Only pair and offer server saves these emulators wrote. */ + emulators?: string[]; +}) { + return api.post("/sync/negotiate", { + device_id: deviceId, + rom_ids: romIds, + saves, + restore_unlisted: restoreUnlisted, + emulators, + }); +} + +async function completeSession( + sessionId: number, + payload: SyncCompletePayload, +) { + return api.post(`/sync/sessions/${sessionId}/complete`, payload); +} + +/** A save's bytes, recorded against the device as the version it now holds. */ +async function downloadSave({ + saveId, + deviceId, + sessionId, +}: { + saveId: number; + deviceId: string; + sessionId: number; +}) { + return api.get(`/saves/${saveId}/content`, { + params: { device_id: deviceId, session_id: sessionId, optimistic: true }, + responseType: "arraybuffer", + }); +} + +/** Confirm the device wrote a downloaded save, with its own hash of the bytes. */ +async function confirmDownloaded({ + saveId, + deviceId, + contentHash, +}: { + saveId: number; + deviceId: string; + contentHash: string; +}) { + return api.post(`/saves/${saveId}/downloaded`, { + device_id: deviceId, + content_hash: contentHash, + }); +} + +export default { + registerDevice, + negotiate, + completeSession, + downloadSave, + confirmDownloaded, +}; diff --git a/frontend/src/services/pending-asset.test.ts b/frontend/src/services/pending-asset.test.ts index 7095e6c3d..981387ae4 100644 --- a/frontend/src/services/pending-asset.test.ts +++ b/frontend/src/services/pending-asset.test.ts @@ -249,6 +249,7 @@ describe("syncPendingAssets", () => { autocleanup: false, overwrite: false, }); + expect(request.contentHash).toMatch(/^[0-9a-f]{32}$/); expect(request.savesToUpload[0].screenshotFile.name).toBe("game.png"); expect(rows.size).toBe(0); }); diff --git a/frontend/src/services/pending-asset.ts b/frontend/src/services/pending-asset.ts index fbe9d3177..e00486fdb 100644 --- a/frontend/src/services/pending-asset.ts +++ b/frontend/src/services/pending-asset.ts @@ -1,6 +1,7 @@ // Saves and states the server has not taken yet, held in the browser with the // frame captured when the game wrote them, until a later pass hands them over. import axios from "axios"; +import type { SaveSchema } from "@/__generated__"; import { isCsrfFailure } from "@/services/api"; import romApi from "@/services/api/rom"; import saveApi, { @@ -14,6 +15,8 @@ import stateApi, { } from "@/services/api/state"; import storeAuth from "@/stores/auth"; import { errorMessage } from "@/v2/utils/errorMessage"; +import { openDb } from "@/v2/utils/idb"; +import { saveContentHash } from "@/v2/utils/saveSync/hash"; const DB_NAME = "romm-player"; const DB_VERSION = 6; @@ -54,7 +57,7 @@ interface HeldKey { // `crypto.randomUUID` needs a secure context, which plain http on a LAN address // is not. The id only has to be unique within a browser. -function randomToken(): string { +export function randomToken(): string { const webCrypto = globalThis.crypto; if (typeof webCrypto?.randomUUID === "function") return webCrypto.randomUUID(); @@ -66,47 +69,27 @@ export function pendingAssetId(romId: number): string { return `${romId}:${randomToken()}`; } +// The rows are the player's unsynced progress, so an upgrade keeps them: it +// only drops the stores this version has stopped using. +function upgradeDatabase(request: IDBOpenDBRequest) { + const db = request.result; + const names = Array.from(db.objectStoreNames); + for (const name of names) { + if (name !== STORE_NAME) db.deleteObjectStore(name); + } + const store = names.includes(STORE_NAME) + ? request.transaction!.objectStore(STORE_NAME) + : db.createObjectStore(STORE_NAME, { keyPath: "id" }); + for (const name of Array.from(store.indexNames)) { + if (name !== OWNER_INDEX) store.deleteIndex(name); + } + if (!store.indexNames.contains(OWNER_INDEX)) { + store.createIndex(OWNER_INDEX, ["userId", "kind", "romId", "capturedAt"]); + } +} + function openDatabase(): Promise { - return new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, DB_VERSION); - let abandoned = false; - // The rows are the player's unsynced progress, so an upgrade keeps them: - // it only drops the stores this version has stopped using. - request.onupgradeneeded = () => { - const db = request.result; - const names = Array.from(db.objectStoreNames); - for (const name of names) { - if (name !== STORE_NAME) db.deleteObjectStore(name); - } - const store = names.includes(STORE_NAME) - ? request.transaction!.objectStore(STORE_NAME) - : db.createObjectStore(STORE_NAME, { keyPath: "id" }); - for (const name of Array.from(store.indexNames)) { - if (name !== OWNER_INDEX) store.deleteIndex(name); - } - if (!store.indexNames.contains(OWNER_INDEX)) { - store.createIndex(OWNER_INDEX, [ - "userId", - "kind", - "romId", - "capturedAt", - ]); - } - }; - request.onsuccess = () => { - // The open landed after the wait was given up on; nothing holds the - // connection now, and leaving it open blocks every other tab's upgrade. - if (abandoned) return request.result.close(); - resolve(request.result); - }; - request.onerror = () => reject(request.error); - // Another tab on an older version holds the upgrade off, and none of the - // handlers above fires meanwhile: without this the caller waits forever. - request.onblocked = () => { - abandoned = true; - reject(new Error("Pending asset storage is open in another tab")); - }; - }); + return openDb(DB_NAME, DB_VERSION, upgradeDatabase, "Pending asset storage"); } // Storage is a best effort: a private window, a blocked origin or a failed @@ -222,7 +205,9 @@ async function uploadTarget( } // A slot already holding newer progress from another device. -function isSlotConflict(result?: PromiseSettledResult): boolean { +export function isSlotConflict( + result?: PromiseSettledResult, +): boolean { return ( result?.status === "rejected" && axios.isAxiosError(result.reason) && @@ -232,21 +217,36 @@ function isSlotConflict(result?: PromiseSettledResult): boolean { // An archived save sits outside every slot, so it replaces nothing; it takes // the stem a state does, the rom and the moment of the capture. -async function archiveSave( - entry: PendingAsset, - rom: { id: number; fs_name_no_ext: string }, -): Promise | undefined> { - const name = sessionStateName(rom, new Date(entry.capturedAt)); +export async function uploadArchivedSave({ + rom, + emulator, + deviceId, + capturedAt, + bytes, + extension = ".srm", + screenshotBytes, +}: { + rom: { id: number; fs_name_no_ext: string }; + emulator?: string | undefined; + deviceId?: string | undefined; + capturedAt: Date; + bytes: ArrayBuffer | Uint8Array; + extension?: string; + screenshotBytes?: ArrayBuffer | undefined; +}): Promise | undefined> { + const name = sessionStateName(rom, capturedAt); const type = "application/octet-stream"; const [uploaded] = await saveApi.uploadSaves({ rom, - emulator: entry.emulator, - deviceId: entry.deviceId, + emulator, + deviceId, savesToUpload: [ { - saveFile: new File([entry.bytes], `${name}.srm`, { type }), - screenshotFile: entry.screenshotBytes - ? new File([entry.screenshotBytes], `${name}.png`, { type }) + saveFile: new File([bytes as BlobPart], `${name}${extension}`, { + type, + }), + screenshotFile: screenshotBytes + ? new File([screenshotBytes], `${name}.png`, { type }) : undefined, }, ], @@ -254,6 +254,20 @@ async function archiveSave( return uploaded; } +function archiveSave( + entry: PendingAsset, + rom: { id: number; fs_name_no_ext: string }, +): Promise | undefined> { + return uploadArchivedSave({ + rom, + emulator: entry.emulator, + deviceId: entry.deviceId, + capturedAt: new Date(entry.capturedAt), + bytes: entry.bytes, + screenshotBytes: entry.screenshotBytes, + }); +} + async function uploadSave( entry: PendingAsset, rom: { id: number; fs_name_no_ext: string }, @@ -271,6 +285,7 @@ async function uploadSave( // The dedupe is the point: a retry of bytes the server already has must // return that version, not mint another one. overwrite: false, + contentHash: saveContentHash(new Uint8Array(entry.bytes)), savesToUpload: [ { saveFile: sessionSaveFile(rom, null, entry.bytes), diff --git a/frontend/src/test-utils/deviceSaveSync.ts b/frontend/src/test-utils/deviceSaveSync.ts new file mode 100644 index 000000000..9e3f91858 --- /dev/null +++ b/frontend/src/test-utils/deviceSaveSync.ts @@ -0,0 +1,26 @@ +import { vi } from "vitest"; + +// Stands in for `@/v2/utils/saveSync` via `vi.mock(path, () => import(...))`, +// for suites that drive a player; the sync logic has its own suite. +export const PLAYER_SAVE_POLL_MS = 5000; + +export const saveSyncMocks = { + /** The constructor arguments of the latest instance. */ + args: [] as unknown[], + capture: vi.fn(), + captureOnUnload: vi.fn(), + prepare: vi.fn(), + push: vi.fn(), + pushOnUnload: vi.fn(), +}; + +export class DeviceSaveSync { + constructor(...args: unknown[]) { + saveSyncMocks.args = args; + } + capture = saveSyncMocks.capture; + captureOnUnload = saveSyncMocks.captureOnUnload; + prepare = saveSyncMocks.prepare; + push = saveSyncMocks.push; + pushOnUnload = saveSyncMocks.pushOnUnload; +} diff --git a/frontend/src/types/js-dos.d.ts b/frontend/src/types/js-dos.d.ts index 0261b9c50..ad791933b 100644 --- a/frontend/src/types/js-dos.d.ts +++ b/frontend/src/types/js-dos.d.ts @@ -7,8 +7,12 @@ export interface JsDosOptions { autoSave: boolean; fullScreen: boolean; fsChanges: { - local: boolean; + local?: boolean; urlToKey?: (url: string) => Promise; + /** Supplies the saved changes in place of browser storage. */ + pull?: (key: string) => Promise; + /** Receives the changes on each save in place of browser storage; a throw fails the save. */ + push?: (key: string, changes: Uint8Array) => Promise; }; } diff --git a/frontend/src/v2/components/AppShell/UserMenu.vue b/frontend/src/v2/components/AppShell/UserMenu.vue index 34c0a0f30..b787709c2 100644 --- a/frontend/src/v2/components/AppShell/UserMenu.vue +++ b/frontend/src/v2/components/AppShell/UserMenu.vue @@ -82,6 +82,7 @@ const canSeeConversionSettings = computed( () => canSeeScanSettings.value && heartbeat.value.CONVERTO.ENABLED, ); const canSeeApiTokens = computed(() => scopes.value.includes("me.write")); +const canSeeDevices = computed(() => scopes.value.includes("devices.read")); const canSeeAdmin = computed(() => scopes.value.includes("users.write")); function showAbout() { @@ -264,6 +265,13 @@ async function onLogout() { :label="t('settings.client-api-tokens')" @click="open = false" /> + diff --git a/frontend/src/v2/components/Settings/RenameDeviceDialog.vue b/frontend/src/v2/components/Settings/RenameDeviceDialog.vue new file mode 100644 index 000000000..4a6d87645 --- /dev/null +++ b/frontend/src/v2/components/Settings/RenameDeviceDialog.vue @@ -0,0 +1,94 @@ + + + diff --git a/frontend/src/v2/components/Settings/SettingsSidebar.vue b/frontend/src/v2/components/Settings/SettingsSidebar.vue index a30337e48..5ef6c8671 100644 --- a/frontend/src/v2/components/Settings/SettingsSidebar.vue +++ b/frontend/src/v2/components/Settings/SettingsSidebar.vue @@ -130,6 +130,12 @@ const groups = computed(() => { to: { name: ROUTES.CLIENT_API_TOKENS }, visible: scopes.value.includes("me.write"), }, + { + icon: "mdi-devices", + label: t("settings.devices"), + to: { name: ROUTES.DEVICES }, + visible: scopes.value.includes("devices.read"), + }, ], }, { diff --git a/frontend/src/v2/composables/useDeviceSaveSync/index.test.ts b/frontend/src/v2/composables/useDeviceSaveSync/index.test.ts new file mode 100644 index 000000000..72d64de29 --- /dev/null +++ b/frontend/src/v2/composables/useDeviceSaveSync/index.test.ts @@ -0,0 +1,207 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { effectScope, type EffectScope } from "vue"; +import { saveSyncMocks } from "@/test-utils/deviceSaveSync"; +import { useDeviceSaveSync, type DeviceSaveSyncOptions } from "./index"; + +const mocks = vi.hoisted(() => ({ confirm: vi.fn() })); + +vi.mock("vue-i18n"); + +vi.mock("@/stores/auth", () => ({ + default: () => ({ user: { id: 7 } }), +})); + +vi.mock("@/v2/composables/useConfirm", () => ({ + useConfirm: () => mocks.confirm, +})); + +vi.mock("@/v2/utils/saveSync", () => import("@/test-utils/deviceSaveSync")); + +const ROM = { id: 1, fs_name_no_ext: "Game" }; +const FILE = { + slot: "a", + fileName: "a.sav", + bytes: new Uint8Array([1]), + updatedAt: 1, +}; + +let scope: EffectScope | null = null; + +function use(options: Partial = {}) { + scope = effectScope(); + return scope.run(() => useDeviceSaveSync({ emulator: "test", ...options }))!; +} + +async function started(options: Partial = {}) { + const saveSync = use(options); + await saveSync.prepare(ROM); + saveSync.start(); + return saveSync; +} + +function pagehide(persisted = false) { + window.dispatchEvent( + Object.assign(new Event("pagehide"), { persisted }) as PageTransitionEvent, + ); +} + +beforeEach(() => { + saveSyncMocks.prepare.mockResolvedValue([]); + saveSyncMocks.capture.mockResolvedValue(undefined); + saveSyncMocks.push.mockResolvedValue(true); +}); + +afterEach(() => { + scope?.stop(); + scope = null; + vi.useRealTimers(); +}); + +describe("useDeviceSaveSync", () => { + it("prepares as the signed-in user's device for its emulator", async () => { + const saveSync = use(); + + await saveSync.prepare(ROM, [FILE]); + + expect(saveSyncMocks.args).toEqual([ROM, 7, "test"]); + expect(saveSyncMocks.prepare).toHaveBeenCalledWith([FILE]); + expect(saveSync.isActive()).toBe(false); + }); + + it("pushes nothing until started", async () => { + const saveSync = use(); + await saveSync.prepare(ROM); + + expect(await saveSync.push()).toBe(true); + expect(saveSyncMocks.push).not.toHaveBeenCalled(); + }); + + it("captures what it reads before each push", async () => { + const read = vi.fn().mockResolvedValue([FILE]); + const saveSync = await started({ read }); + + expect(await saveSync.push()).toBe(true); + expect(saveSyncMocks.capture).toHaveBeenCalledWith([FILE]); + expect(saveSyncMocks.push).toHaveBeenCalledOnce(); + }); + + it("joins a push already in flight, and flushes after it", async () => { + let release!: (files: (typeof FILE)[]) => void; + const read = vi + .fn() + .mockReturnValueOnce(new Promise((resolve) => (release = resolve))) + .mockResolvedValue([FILE]); + const saveSync = await started({ read }); + + const first = saveSync.push(); + expect(saveSync.push()).toBe(first); + const flushed = saveSync.flush(); + release([]); + + expect(await flushed).toBe(true); + expect(read).toHaveBeenCalledTimes(2); + }); + + it("reports a failed push as false", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + const saveSync = await started({ + read: vi.fn().mockRejectedValue(new Error("blocked")), + }); + + expect(await saveSync.push()).toBe(false); + }); + + it("polls only while running", async () => { + vi.useFakeTimers(); + const read = vi.fn().mockResolvedValue([]); + const saveSync = await started({ read }); + + await vi.advanceTimersByTimeAsync(5000); + expect(read).toHaveBeenCalledOnce(); + + saveSync.pause(); + await vi.advanceTimersByTimeAsync(5000); + expect(read).toHaveBeenCalledOnce(); + }); + + it("polls at its own pace, telling the read it is not leaving", async () => { + vi.useFakeTimers(); + const read = vi.fn().mockResolvedValue([]); + const saveSync = await started({ read, pollMs: 1000 }); + + await vi.advanceTimersByTimeAsync(3000); + expect(read).toHaveBeenCalledTimes(3); + expect(read).toHaveBeenLastCalledWith(false); + + await saveSync.flush(); + expect(read).toHaveBeenLastCalledWith(true); + }); + + it("backs off polled uploads while they fail, still reading", async () => { + vi.useFakeTimers(); + saveSyncMocks.push.mockResolvedValue(false); + const read = vi.fn().mockResolvedValue([]); + await started({ read, pollMs: 1000 }); + + await vi.advanceTimersByTimeAsync(1000); + expect(saveSyncMocks.push).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1000); + expect(saveSyncMocks.push).toHaveBeenCalledTimes(1); + expect(read).toHaveBeenCalledTimes(2); + + saveSyncMocks.push.mockResolvedValue(true); + await vi.advanceTimersByTimeAsync(1000); + expect(saveSyncMocks.push).toHaveBeenCalledTimes(2); + }); + + it("uploads on leaving even while backing off", async () => { + vi.useFakeTimers(); + saveSyncMocks.push.mockResolvedValueOnce(false); + const saveSync = await started({ + read: vi.fn().mockResolvedValue([]), + pollMs: 1000, + }); + await vi.advanceTimersByTimeAsync(1000); + + expect(await saveSync.flush()).toBe(true); + expect(saveSyncMocks.push).toHaveBeenCalledTimes(2); + }); + + it("sends captured changes as the page goes away", async () => { + await started(); + + pagehide(true); + + expect(saveSyncMocks.pushOnUnload).toHaveBeenCalledOnce(); + }); + + it("captures what the player holds as the page goes away", async () => { + await started({ readOnUnload: () => [FILE] }); + + pagehide(true); + expect(saveSyncMocks.captureOnUnload).not.toHaveBeenCalled(); + + pagehide(); + expect(saveSyncMocks.captureOnUnload).toHaveBeenCalledWith([FILE]); + }); + + it("does nothing on the way out once stopped", async () => { + const saveSync = await started(); + + saveSync.stop(); + pagehide(); + + expect(saveSyncMocks.pushOnUnload).not.toHaveBeenCalled(); + }); + + it("asks before discarding unsynced saves", async () => { + mocks.confirm.mockResolvedValue(true); + const saveSync = use(); + + expect(await saveSync.confirmDiscard()).toBe(true); + expect(mocks.confirm).toHaveBeenCalledWith( + expect.objectContaining({ tone: "danger" }), + ); + }); +}); diff --git a/frontend/src/v2/composables/useDeviceSaveSync/index.ts b/frontend/src/v2/composables/useDeviceSaveSync/index.ts new file mode 100644 index 000000000..19de297e7 --- /dev/null +++ b/frontend/src/v2/composables/useDeviceSaveSync/index.ts @@ -0,0 +1,154 @@ +// useDeviceSaveSync: a web player's saves as this browser's sync device, from +// the launch through polled pushes to the page going away. +import { useEventListener, useIntervalFn } from "@vueuse/core"; +import { useI18n } from "vue-i18n"; +import storeAuth from "@/stores/auth"; +import { useConfirm } from "@/v2/composables/useConfirm"; +import { + DeviceSaveSync, + PLAYER_SAVE_POLL_MS, + type LocalSave, + type PlayerSaveFile, + type SaveSyncRom, +} from "@/v2/utils/saveSync"; +import { createRetryBackoff } from "@/v2/utils/saveSync/retryBackoff"; + +export interface DeviceSaveSyncOptions { + emulator: string; + /** + * Reads what the player holds; given, pushes capture it first and can poll. + * + * Args: + * leaving: True when the player is quitting, which cannot wait a poll. + */ + read?: (leaving: boolean) => Promise; + /** Reads what the player holds as the page goes away, which allows no await. */ + readOnUnload?: () => PlayerSaveFile[]; + pollMs?: number; +} + +export function useDeviceSaveSync({ + emulator, + read, + readOnUnload, + pollMs = PLAYER_SAVE_POLL_MS, +}: DeviceSaveSyncOptions) { + const authStore = storeAuth(); + const confirm = useConfirm(); + const { t } = useI18n(); + + let prepared: DeviceSaveSync | null = null; + let sync: DeviceSaveSync | null = null; + let pushing: Promise | null = null; + const retry = createRetryBackoff(); + + /** + * Sync a rom's saves ahead of its launch; `start` then puts them in play. + * + * Returns: + * The saves to hand the player, or null with no one signed in. + */ + async function prepare( + rom: SaveSyncRom, + existing: PlayerSaveFile[] = [], + ): Promise { + const userId = authStore.user?.id; + if (userId == null) return null; + const next = new DeviceSaveSync(rom, userId, emulator); + const saves = await next.prepare(existing); + prepared = next; + return saves; + } + + function run(polled: boolean): Promise { + const active = sync; + if (!active) return Promise.resolve(true); + if (!read) return active.push(); + // A push still in flight already covers this one's work. + pushing ??= read(!polled) + .then((files) => active.capture(files)) + .then(async () => { + // A failing upload waits out its backoff; polls keep reading meanwhile. + if (polled && !retry.ready()) return false; + const pushed = await active.push(); + if (pushed) retry.reset(); + else retry.failed(); + return pushed; + }) + .catch((error: unknown) => { + console.error(`[Save sync] ${emulator} push failed`, error); + retry.failed(); + return false; + }) + .finally(() => { + pushing = null; + }); + return pushing; + } + + /** Upload what changed; false when it failed and stays for the next push. */ + function push(): Promise { + return run(false); + } + + // A push in flight may have read the saves before the latest one landed. + async function flush(): Promise { + await pushing; + return push(); + } + + const poll = useIntervalFn(() => void run(true), pollMs, { + immediate: false, + }); + + function resume() { + if (sync && read) poll.resume(); + } + + function start() { + retry.reset(); + sync = prepared; + prepared = null; + resume(); + } + + function stop() { + poll.pause(); + prepared = null; + sync = null; + } + + /** Keep what the player wrote, for the next push. */ + async function capture(files: PlayerSaveFile[]): Promise { + await sync?.capture(files); + } + + function confirmDiscard(): Promise { + return confirm({ + title: t("play.quit-before-save-synced"), + confirmText: t("common.discard"), + cancelText: t("common.cancel"), + tone: "danger", + }); + } + + useEventListener(window, "pagehide", (event: PageTransitionEvent) => { + if (!sync) return; + if (!readOnUnload) return sync.pushOnUnload(); + // A page kept for back and forward can return to the running game. + if (!event.persisted) sync.captureOnUnload(readOnUnload()); + }); + + return { + prepare, + start, + stop, + push, + flush, + capture, + pause: poll.pause, + resume, + confirmDiscard, + isActive: () => sync !== null, + }; +} diff --git a/frontend/src/v2/router/routes.ts b/frontend/src/v2/router/routes.ts index 849c728af..3b21eff31 100644 --- a/frontend/src/v2/router/routes.ts +++ b/frontend/src/v2/router/routes.ts @@ -64,6 +64,7 @@ export const v2RouteComponents: Partial> = { import("@/v2/views/Settings/MetadataSources.vue"), [ROUTES.CLIENT_API_TOKENS]: () => import("@/v2/views/Settings/ClientApiTokens.vue"), + [ROUTES.DEVICES]: () => import("@/v2/views/Settings/Devices.vue"), [ROUTES.ADMINISTRATION]: () => import("@/v2/views/Settings/Administration.vue"), [ROUTES.SERVER_STATS]: () => import("@/v2/views/Settings/ServerStats.vue"), diff --git a/frontend/src/v2/utils/easyRpgStorage.test.ts b/frontend/src/v2/utils/easyRpgStorage.test.ts new file mode 100644 index 000000000..70196ce18 --- /dev/null +++ b/frontend/src/v2/utils/easyRpgStorage.test.ts @@ -0,0 +1,99 @@ +import "fake-indexeddb/auto"; +import { describe, expect, it } from "vitest"; +import { + deleteEasyRpgSaves, + easyRpgGameName, + readEasyRpgSaves, + writeEasyRpgSaves, +} from "./easyRpgStorage"; + +function openIdbfs(name: string): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(name, 21); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +describe("EasyRPG storage", () => { + it("names the game after the rom and the account", () => { + expect(easyRpgGameName(12, 3)).toBe("12-3"); + }); + + it("reads back what it wrote, one slot per save file", async () => { + await writeEasyRpgSaves("12-3", [ + { slot: "Save01", bytes: new Uint8Array([1]), updatedAt: 1000 }, + { slot: "Save02", bytes: new Uint8Array([2]), updatedAt: 2000 }, + ]); + + const saves = await readEasyRpgSaves("12-3"); + + expect( + saves.map((save) => [ + save.slot, + save.fileName, + [...save.bytes], + save.updatedAt, + ]), + ).toEqual([ + ["Save01", "Save01.lsd", [1], 1000], + ["Save02", "Save02.lsd", [2], 2000], + ]); + }); + + it("deletes the named save files", async () => { + await writeEasyRpgSaves("30-1", [ + { slot: "Save01", bytes: new Uint8Array([1]), updatedAt: 1 }, + { slot: "Save02", bytes: new Uint8Array([2]), updatedAt: 2 }, + ]); + + await deleteEasyRpgSaves("30-1", ["Save01.lsd"]); + + expect((await readEasyRpgSaves("30-1")).map((save) => save.slot)).toEqual([ + "Save02", + ]); + }); + + it("keeps each account's saves apart", async () => { + await writeEasyRpgSaves("40-1", [ + { slot: "Save01", bytes: new Uint8Array([1]), updatedAt: 1 }, + ]); + + expect(await readEasyRpgSaves("40-2")).toEqual([]); + }); + + it("lays the database out as IDBFS does, so the player opens it", async () => { + await writeEasyRpgSaves("50-1", [ + { slot: "Save01", bytes: new Uint8Array([1]), updatedAt: 1 }, + ]); + + const db = await openIdbfs("/easyrpg/50-1/Save"); + const store = db.transaction("FILE_DATA").objectStore("FILE_DATA"); + expect([...store.indexNames]).toEqual(["timestamp"]); + db.close(); + }); + + it("skips files that are not save slots", async () => { + await writeEasyRpgSaves("60-1", [ + { slot: "Save03", bytes: new Uint8Array([3]), updatedAt: 1 }, + ]); + const raw = await openIdbfs("/easyrpg/60-1/Save"); + await new Promise((resolve) => { + const transaction = raw.transaction("FILE_DATA", "readwrite"); + transaction.objectStore("FILE_DATA").put( + { + timestamp: new Date(1), + mode: 0o100666, + contents: new Uint8Array([9]), + }, + "/easyrpg/60-1/Save/config.ini", + ); + transaction.oncomplete = () => resolve(); + }); + raw.close(); + + expect((await readEasyRpgSaves("60-1")).map((save) => save.slot)).toEqual([ + "Save03", + ]); + }); +}); diff --git a/frontend/src/v2/utils/easyRpgStorage.ts b/frontend/src/v2/utils/easyRpgStorage.ts new file mode 100644 index 000000000..5fe6a2236 --- /dev/null +++ b/frontend/src/v2/utils/easyRpgStorage.ts @@ -0,0 +1,129 @@ +import { inTransaction, openDb, settle } from "@/v2/utils/idb"; +import type { PlayerSaveFile } from "@/v2/utils/saveSync"; + +// The EasyRPG web player keeps a game's saves in an Emscripten IDBFS +// database named after the folder it mounts them at, `/easyrpg//Save`. +const IDBFS_VERSION = 21; +const IDBFS_STORE = "FILE_DATA"; +const IDBFS_TIMESTAMP_INDEX = "timestamp"; +// A regular file, readable and writable by all, as Emscripten creates one. +const IDBFS_FILE_MODE = 0o100666; + +const SAVE_FILE = /^(save\d+)\.lsd$/i; +const SAVE_EXTENSION = ".lsd"; + +interface IdbfsEntry { + timestamp: Date; + mode: number; + contents?: Uint8Array; +} + +/** The game name the player is launched with, which also names its saves. */ +export function easyRpgGameName(romId: number, userId: number): string { + return `${romId}-${userId}`; +} + +function saveDir(game: string): string { + // The player lowercases the game name before mounting. + return `/easyrpg/${game.toLowerCase()}/Save`; +} + +// Laid out as IDBFS lays it out, so the player opens it without an upgrade. +function upgradeSaveDb(request: IDBOpenDBRequest) { + const db = request.result; + const store = db.objectStoreNames.contains(IDBFS_STORE) + ? request.transaction!.objectStore(IDBFS_STORE) + : db.createObjectStore(IDBFS_STORE); + if (!store.indexNames.contains(IDBFS_TIMESTAMP_INDEX)) { + store.createIndex(IDBFS_TIMESTAMP_INDEX, IDBFS_TIMESTAMP_INDEX, { + unique: false, + }); + } +} + +function openSaveDb(game: string): Promise { + return openDb( + saveDir(game), + IDBFS_VERSION, + upgradeSaveDb, + "EasyRPG save storage", + ); +} + +async function withSaveStore( + game: string, + mode: IDBTransactionMode, + run: (store: IDBObjectStore) => Promise, +): Promise { + return inTransaction(await openSaveDb(game), IDBFS_STORE, mode, run); +} + +/** The save files the player holds for `game`, one slot per `SaveNN.lsd`. */ +export function readEasyRpgSaves(game: string): Promise { + const prefix = `${saveDir(game)}/`; + return withSaveStore(game, "readonly", async (store) => { + const [keys, entries] = await Promise.all([ + settle(store.getAllKeys()), + settle(store.getAll() as IDBRequest), + ]); + const saves: PlayerSaveFile[] = []; + keys.forEach((key, index) => { + const entry = entries[index]; + if ( + typeof key !== "string" || + !key.startsWith(prefix) || + !entry?.contents + ) + return; + const fileName = key.slice(prefix.length); + const match = SAVE_FILE.exec(fileName); + if (!match) return; + saves.push({ + slot: match[1]!, + fileName, + bytes: entry.contents, + updatedAt: entry.timestamp.getTime(), + }); + }); + return saves; + }); +} + +/** Hand `saves` to the player, which loads them when it next starts `game`. */ +export function writeEasyRpgSaves( + game: string, + saves: { slot: string; bytes: Uint8Array; updatedAt: number }[], +): Promise { + if (saves.length === 0) return Promise.resolve(); + return withSaveStore(game, "readwrite", async (store) => { + await Promise.all( + saves.map((save) => + settle( + store.put( + { + timestamp: new Date(save.updatedAt), + mode: IDBFS_FILE_MODE, + contents: save.bytes, + } satisfies IdbfsEntry, + `${saveDir(game)}/${save.slot}${SAVE_EXTENSION}`, + ), + ), + ), + ); + }); +} + +/** Remove the files named `fileNames` from the player's save folder. */ +export function deleteEasyRpgSaves( + game: string, + fileNames: string[], +): Promise { + if (fileNames.length === 0) return Promise.resolve(); + return withSaveStore(game, "readwrite", async (store) => { + await Promise.all( + fileNames.map((fileName) => + settle(store.delete(`${saveDir(game)}/${fileName}`)), + ), + ); + }); +} diff --git a/frontend/src/v2/utils/idb.test.ts b/frontend/src/v2/utils/idb.test.ts new file mode 100644 index 000000000..b41337bba --- /dev/null +++ b/frontend/src/v2/utils/idb.test.ts @@ -0,0 +1,26 @@ +import "fake-indexeddb/auto"; +import { describe, expect, it, vi } from "vitest"; +import { openDb } from "./idb"; + +describe("openDb", () => { + it("runs the upgrade on a new database", async () => { + const upgrade = vi.fn((request: IDBOpenDBRequest) => + request.result.createObjectStore("rows"), + ); + + const db = await openDb("idb-new", 1, upgrade, "Rows"); + + expect(upgrade).toHaveBeenCalledOnce(); + expect([...db.objectStoreNames]).toEqual(["rows"]); + db.close(); + }); + + it("rejects instead of waiting when another tab holds an older version", async () => { + const older = await openDb("idb-blocked", 1, () => undefined, "Rows"); + + await expect( + openDb("idb-blocked", 2, () => undefined, "Rows"), + ).rejects.toThrow("Rows is open in another tab"); + older.close(); + }); +}); diff --git a/frontend/src/v2/utils/idb.ts b/frontend/src/v2/utils/idb.ts new file mode 100644 index 000000000..75232be79 --- /dev/null +++ b/frontend/src/v2/utils/idb.ts @@ -0,0 +1,62 @@ +/** The result of an IndexedDB request, once it succeeds. */ +export function settle(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +/** + * Open the database `name` at `version`, running `upgrade` when it is older. + * + * Args: + * label: What the database holds, for the error when another tab blocks it. + */ +export function openDb( + name: string, + version: number, + upgrade: (request: IDBOpenDBRequest) => void, + label: string, +): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(name, version); + let abandoned = false; + request.onupgradeneeded = () => upgrade(request); + request.onsuccess = () => { + // Opened after the wait was given up on; left open it blocks other tabs. + if (abandoned) return request.result.close(); + resolve(request.result); + }; + request.onerror = () => reject(request.error); + // Another tab on an older version holds the upgrade off, and no other + // handler fires meanwhile, so without this the caller waits forever. + request.onblocked = () => { + abandoned = true; + reject(new Error(`${label} is open in another tab`)); + }; + }); +} + +/** Run `run` in one transaction on `storeName`, then close `db` once it commits. */ +export async function inTransaction( + db: IDBDatabase, + storeName: string, + mode: IDBTransactionMode, + run: (store: IDBObjectStore) => Promise, +): Promise { + try { + const transaction = db.transaction(storeName, mode); + const committed = new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => reject(transaction.error); + }); + const [result] = await Promise.all([ + run(transaction.objectStore(storeName)), + committed, + ]); + return result; + } finally { + db.close(); + } +} diff --git a/frontend/src/v2/utils/pico8Runtime.test.ts b/frontend/src/v2/utils/pico8Runtime.test.ts index 560208b3e..f20b5c425 100644 --- a/frontend/src/v2/utils/pico8Runtime.test.ts +++ b/frontend/src/v2/utils/pico8Runtime.test.ts @@ -14,8 +14,21 @@ const context = { // Hand out distinct addresses so the palette and audio buffers do not alias. let nextAddress = 200; +const files = new Map(); const fakeModule = { HEAPU8: heap, + FS: { + mkdirTree: vi.fn(), + writeFile: vi.fn((path: string, data: Uint8Array) => { + files.set(path, data); + }), + readFile: vi.fn((path: string) => files.get(path)!), + readdir: vi.fn(() => [ + ".", + "..", + ...[...files.keys()].map((path) => path.split("/").pop()!), + ]), + }, _f08_init: vi.fn(), _f08_load_cart_data: vi.fn(() => 0), _f08_step_frame: vi.fn(), @@ -52,6 +65,7 @@ vi.mock("./scriptLoader", () => ({ })); beforeEach(() => { + files.clear(); nextAddress = 200; heap[100] = 0x21; vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue( @@ -112,4 +126,22 @@ describe("createPico8Runtime", () => { ).rejects.toThrow(/out of memory/); expect(fakeModule._free).toHaveBeenCalledWith(200); }); + + it("restores cart data before a cart and flushes it on the way out", async () => { + const runtime = await createPico8Runtime(document.createElement("canvas")); + + runtime.writeCartData([{ key: "celeste", bytes: new Uint8Array([1]) }]); + expect(fakeModule.FS.mkdirTree).toHaveBeenCalledWith("/fake08/cdata"); + expect(files.get("/fake08/cdata/celeste.p8d.txt")).toEqual( + new Uint8Array([1]), + ); + + fakeModule._f08_load_cart_data.mockImplementationOnce(() => { + files.set("/fake08/cdata/celeste.p8d.txt", new Uint8Array([2])); + return 0; + }); + expect(runtime.flushCartData()).toEqual([ + { key: "celeste", bytes: new Uint8Array([2]) }, + ]); + }); }); diff --git a/frontend/src/v2/utils/pico8Runtime.ts b/frontend/src/v2/utils/pico8Runtime.ts index 18c9a660b..2d0eec554 100644 --- a/frontend/src/v2/utils/pico8Runtime.ts +++ b/frontend/src/v2/utils/pico8Runtime.ts @@ -17,9 +17,35 @@ const PICO8_WASM_PATH = "/assets/pico8/fake08.wasm"; const PICO8_SCRIPT_PATH = "/assets/pico8/fake08.js"; const FRAMEBUFFER_BYTES = (PICO8_WIDTH * PICO8_HEIGHT) / 2; const PALETTE_BYTES = 16 * 4; +// FAKE-08 keeps each `cartdata()` key in its own file here, in memory only. +const CART_DATA_DIR = "/fake08/cdata"; +const CART_DATA_SUFFIX = ".p8d.txt"; +// Loading any cart makes FAKE-08 write the running cart's data out first. +const BLANK_CART = new TextEncoder().encode( + "pico-8 cartridge // http://www.pico-8.com\nversion 41\n__lua__\nfunction _draw() end\n", +); + +/** A cart's `cartdata()` file, named by the key the cart chose. */ +export interface Pico8CartData { + key: string; + bytes: Uint8Array; +} + +/** The file FAKE-08 reads a `cartdata()` key from. */ +export function cartDataFileName(key: string): string { + return `${key}${CART_DATA_SUFFIX}`; +} + +interface Fake08FileSystem { + mkdirTree: (path: string) => void; + writeFile: (path: string, data: Uint8Array) => void; + readFile: (path: string) => Uint8Array; + readdir: (path: string) => string[]; +} export interface Fake08Module { HEAPU8: Uint8Array; + FS: Fake08FileSystem; _f08_init: () => void; _f08_load_cart_data: (pointer: number, length: number) => number; _f08_step_frame: () => void; @@ -92,6 +118,10 @@ export interface Pico8Runtime { /** Samples a single frame can produce, for sizing the caller's buffer. */ readonly samplesPerFrame: number; loadCart: (bytes: Uint8Array) => void; + /** Hand the next cart the data it saved before, ahead of `loadCart`. */ + writeCartData: (files: Pico8CartData[]) => void; + /** Unload the cart so it writes its data out, then read it; for leaving only. */ + flushCartData: () => Pico8CartData[]; advance: (input: Pico8Input) => void; render: () => void; /** @@ -198,6 +228,29 @@ export async function createPico8Runtime( } } + function writeCartData(files: Pico8CartData[]) { + module.FS.mkdirTree(CART_DATA_DIR); + for (const file of files) { + module.FS.writeFile( + `${CART_DATA_DIR}/${cartDataFileName(file.key)}`, + file.bytes, + ); + } + } + + function flushCartData(): Pico8CartData[] { + if (disposed) return []; + // Without the directory FAKE-08 drops the write silently. + module.FS.mkdirTree(CART_DATA_DIR); + loadCart(BLANK_CART); + return module.FS.readdir(CART_DATA_DIR) + .filter((name) => name.endsWith(CART_DATA_SUFFIX)) + .map((name) => ({ + key: name.slice(0, -CART_DATA_SUFFIX.length), + bytes: module.FS.readFile(`${CART_DATA_DIR}/${name}`), + })); + } + function advance(input: Pico8Input) { if (disposed) return; module._f08_set_inputs( @@ -233,6 +286,8 @@ export async function createPico8Runtime( audioSampleRate: module._f08_get_audio_sample_rate(), samplesPerFrame, loadCart, + writeCartData, + flushCartData, advance, render, readAudio, diff --git a/frontend/src/v2/utils/ruffleSaves.test.ts b/frontend/src/v2/utils/ruffleSaves.test.ts new file mode 100644 index 000000000..0ae45da2e --- /dev/null +++ b/frontend/src/v2/utils/ruffleSaves.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + readRuffleSaves, + removeRuffleSaves, + readRuffleStorage, + sameRuffleStorage, + swfStoragePath, + unzipRuffleSaves, + writeRuffleSaves, + zipRuffleSaves, +} from "./ruffleSaves"; + +const HOST = "romm.local"; +const SWF_PATH = "api/roms/1/content/game.swf"; + +function sol(marker: number): Uint8Array { + return new Uint8Array([ + 0x00, + 0xbf, + 0, + 0, + 0, + 9, + 0x54, + 0x43, + 0x53, + 0x4f, + marker, + ]); +} + +function stored(bytes: Uint8Array): string { + return btoa(String.fromCharCode(...bytes)); +} + +beforeEach(() => localStorage.clear()); + +describe("ruffle saves", () => { + it("compares two reads of storage without decoding them", () => { + localStorage.setItem(`${HOST}/${SWF_PATH}/a`, stored(sol(1))); + const before = readRuffleStorage(HOST, SWF_PATH); + + expect(sameRuffleStorage(before, readRuffleStorage(HOST, SWF_PATH))).toBe( + true, + ); + localStorage.setItem(`${HOST}/${SWF_PATH}/a`, stored(sol(2))); + expect(sameRuffleStorage(before, readRuffleStorage(HOST, SWF_PATH))).toBe( + false, + ); + localStorage.setItem(`${HOST}/${SWF_PATH}/b`, stored(sol(1))); + expect(sameRuffleStorage({ a: "x" }, { b: "x" })).toBe(false); + }); + + it("derives the storage path from the SWF URL, without its query", () => { + expect(swfStoragePath("/api/roms/1/content/game.swf?purpose=play")).toBe( + SWF_PATH, + ); + }); + + it("reads the SharedObjects the SWF can reach, and nothing else", () => { + localStorage.setItem(`${HOST}/${SWF_PATH}/progress`, stored(sol(1))); + localStorage.setItem(`${HOST}/api/roms/shared`, stored(sol(2))); + localStorage.setItem(`${HOST}//root`, stored(sol(3))); + localStorage.setItem(`${HOST}/${SWF_PATH}/#a/b`, stored(sol(4))); + localStorage.setItem( + `${HOST}/api/roms/2/content/other.swf/x`, + stored(sol(5)), + ); + localStorage.setItem(`other.host/${SWF_PATH}/progress`, stored(sol(6))); + localStorage.setItem(`${HOST}/${SWF_PATH}/broken`, "not base64!"); + localStorage.setItem("player:ruffle:1:backgroundColor", "#000000"); + + const saves = readRuffleSaves(HOST, SWF_PATH); + + expect(Object.keys(saves).sort()).toEqual( + [ + `${SWF_PATH}/progress`, + "api/roms/shared", + "/root", + `${SWF_PATH}/#a/b`, + ].sort(), + ); + expect(saves[`${SWF_PATH}/progress`]).toEqual(sol(1)); + }); + + it("round-trips through a zip and back into storage", () => { + const saves = { [`${SWF_PATH}/progress`]: sol(1), "/root": sol(3) }; + + const restored = unzipRuffleSaves(zipRuffleSaves(saves), SWF_PATH); + writeRuffleSaves(HOST, restored); + + expect(readRuffleSaves(HOST, SWF_PATH)).toEqual(saves); + + removeRuffleSaves(HOST, Object.keys(saves)); + expect(localStorage.length).toBe(0); + }); + + it("restores only the entries the SWF can reach", () => { + const archive = zipRuffleSaves({ + [`${SWF_PATH}/progress`]: sol(1), + "api/roms/2/content/other.swf/progress": sol(2), + }); + + expect(Object.keys(unzipRuffleSaves(archive, SWF_PATH))).toEqual([ + `${SWF_PATH}/progress`, + ]); + }); +}); diff --git a/frontend/src/v2/utils/ruffleSaves.ts b/frontend/src/v2/utils/ruffleSaves.ts new file mode 100644 index 000000000..8d4a944e4 --- /dev/null +++ b/frontend/src/v2/utils/ruffleSaves.ts @@ -0,0 +1,140 @@ +// Ruffle keeps each SharedObject in localStorage as a base64 `.sol` file, keyed +// `//`, a `/` in a name prefixed with `#`. +// Ruffle picks those keys itself, so they cannot be scoped to the user. +/* eslint-disable romm/no-unscoped-local-storage */ +import { unzipSync, zipSync } from "fflate"; + +/** A game's SharedObjects, keyed by their storage key without the host. */ +export type RuffleSaves = Record; +/** The same SharedObjects as stored, still base64, for a cheap comparison. */ +export type RuffleStorage = Record; + +const SOL_MAGIC = [0x00, 0xbf]; +const SOL_SIGNATURE = "TCSO"; +const SOL_SIGNATURE_OFFSET = 6; + +function fromBase64(value: string): Uint8Array | null { + try { + return Uint8Array.from(atob(value), (char) => char.charCodeAt(0)); + } catch { + return null; + } +} + +function toBase64(bytes: Uint8Array): string { + let binary = ""; + for (let start = 0; start < bytes.length; start += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(start, start + 0x8000)); + } + return btoa(binary); +} + +function isSolFile(bytes: Uint8Array): boolean { + if (bytes[0] !== SOL_MAGIC[0] || bytes[1] !== SOL_MAGIC[1]) return false; + const signature = String.fromCharCode( + ...bytes.subarray( + SOL_SIGNATURE_OFFSET, + SOL_SIGNATURE_OFFSET + SOL_SIGNATURE.length, + ), + ); + return signature === SOL_SIGNATURE; +} + +/** The path Ruffle files a SWF's SharedObjects under, as it derives it. */ +export function swfStoragePath(swfUrl: string): string { + return new URL(swfUrl, document.baseURI).pathname.replace(/^\/+|\/+$/g, ""); +} + +// The SWF's own path and every ancestor, down to the root's empty segment. +function storageScopes(swfPath: string): string[] { + const segments = swfPath.split("/").filter(Boolean); + return segments + .map((_, index, all) => all.slice(0, all.length - index).join("/")) + .concat(""); +} + +function ownsKey(entry: string, scopes: string[]): boolean { + return scopes.some((scope) => { + if (!entry.startsWith(`${scope}/`)) return false; + const name = entry.slice(scope.length + 1); + // A deeper path belongs to another SWF; only `#` names may hold a slash. + return name.length > 0 && (!name.includes("/") || name.startsWith("#")); + }); +} + +function storageKeys(): string[] { + try { + return Array.from({ length: localStorage.length }, (_, index) => + localStorage.key(index), + ).filter((key): key is string => key !== null); + } catch { + return []; + } +} + +/** The stored entries a SWF can reach on this host, undecoded. */ +export function readRuffleStorage( + host: string, + swfPath: string, +): RuffleStorage { + const prefix = `${host}/`; + const scopes = storageScopes(swfPath); + const stored: RuffleStorage = {}; + for (const key of storageKeys()) { + if (!key.startsWith(prefix)) continue; + const entry = key.slice(prefix.length); + if (ownsKey(entry, scopes)) stored[entry] = localStorage.getItem(key) ?? ""; + } + return stored; +} + +/** The SharedObjects among stored entries, skipping any that are not one. */ +export function decodeRuffleStorage(stored: RuffleStorage): RuffleSaves { + const saves: RuffleSaves = {}; + for (const [entry, value] of Object.entries(stored)) { + const bytes = fromBase64(value); + if (bytes && isSolFile(bytes)) saves[entry] = bytes; + } + return saves; +} + +/** The SharedObjects a SWF can reach on this host. */ +export function readRuffleSaves(host: string, swfPath: string): RuffleSaves { + return decodeRuffleStorage(readRuffleStorage(host, swfPath)); +} + +export function writeRuffleSaves(host: string, saves: RuffleSaves): void { + for (const [entry, bytes] of Object.entries(saves)) { + localStorage.setItem(`${host}/${entry}`, toBase64(bytes)); + } +} + +export function removeRuffleSaves(host: string, entries: string[]): void { + for (const entry of entries) localStorage.removeItem(`${host}/${entry}`); +} + +/** Whether two reads of storage hold the same entries. */ +export function sameRuffleStorage(a: RuffleStorage, b: RuffleStorage): boolean { + const entries = Object.keys(a); + return ( + entries.length === Object.keys(b).length && + entries.every((entry) => a[entry] === b[entry]) + ); +} + +export function zipRuffleSaves(saves: RuffleSaves): Uint8Array { + return zipSync(saves); +} + +/** The SharedObjects in an archive that the SWF at `swfPath` can reach. */ +export function unzipRuffleSaves( + bytes: Uint8Array, + swfPath: string, +): RuffleSaves { + const scopes = storageScopes(swfPath); + const saves: RuffleSaves = {}; + for (const [entry, content] of Object.entries(unzipSync(bytes))) { + if (ownsKey(entry, scopes) && isSolFile(content)) saves[entry] = content; + } + return saves; +} diff --git a/frontend/src/v2/utils/saveSync/browserDevice.test.ts b/frontend/src/v2/utils/saveSync/browserDevice.test.ts new file mode 100644 index 000000000..17b855127 --- /dev/null +++ b/frontend/src/v2/utils/saveSync/browserDevice.test.ts @@ -0,0 +1,63 @@ +import { AxiosError, AxiosHeaders } from "axios"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { browserDeviceId, cachedBrowserDeviceId } from "./browserDevice"; + +const mocks = vi.hoisted(() => ({ registerDevice: vi.fn() })); + +vi.mock("@/services/api/sync", () => ({ + default: { registerDevice: mocks.registerDevice }, +})); + +beforeEach(() => { + localStorage.clear(); + mocks.registerDevice.mockReset(); + mocks.registerDevice.mockResolvedValue({ data: { device_id: "device-1" } }); +}); + +describe("browserDeviceId", () => { + it("registers this browser under one id across accounts", async () => { + expect(await browserDeviceId(1)).toBe("device-1"); + await browserDeviceId(2); + + expect(mocks.registerDevice).toHaveBeenCalledTimes(2); + const [first, second] = mocks.registerDevice.mock.calls.map( + ([payload]) => payload, + ); + expect(first).toMatchObject({ + platform: "Web", + sync_mode: "api", + allow_existing: true, + }); + expect(first.hostname).toBeTruthy(); + expect(second.hostname).toBe(first.hostname); + }); + + it("registers again each time, so a removed device comes back", async () => { + await browserDeviceId(1); + mocks.registerDevice.mockResolvedValue({ data: { device_id: "device-2" } }); + + expect(await browserDeviceId(1)).toBe("device-2"); + expect(cachedBrowserDeviceId(1)).toBe("device-2"); + }); + + it("falls back to the cached device when the server is unreachable", async () => { + await browserDeviceId(1); + mocks.registerDevice.mockRejectedValue(new AxiosError("offline", "ERR")); + + expect(await browserDeviceId(1)).toBe("device-1"); + }); + + it("gives no device to an account that cannot register one", async () => { + mocks.registerDevice.mockRejectedValue( + new AxiosError("forbidden", "ERR", undefined, undefined, { + status: 403, + statusText: "", + data: null, + headers: {}, + config: { headers: new AxiosHeaders() }, + }), + ); + + expect(await browserDeviceId(1)).toBeNull(); + }); +}); diff --git a/frontend/src/v2/utils/saveSync/browserDevice.ts b/frontend/src/v2/utils/saveSync/browserDevice.ts new file mode 100644 index 000000000..2db634176 --- /dev/null +++ b/frontend/src/v2/utils/saveSync/browserDevice.ts @@ -0,0 +1,75 @@ +import { isAxiosError } from "axios"; +import Bowser from "bowser"; +import syncApi from "@/services/api/sync"; +import { randomToken } from "@/services/pending-asset"; + +// One id per browser profile, sent as the device's hostname so registering +// again finds the same device instead of adding one. +// The browser id is per profile and the device keys carry the user id. +/* eslint-disable romm/no-unscoped-local-storage */ +const BROWSER_ID_KEY = "romm:browser-id"; +const DEVICE_KEY_PREFIX = "romm:browser-device:"; + +function readStorage(key: string): string | null { + try { + return localStorage.getItem(key); + } catch { + return null; + } +} + +function writeStorage(key: string, value: string) { + try { + localStorage.setItem(key, value); + } catch { + // Without storage the browser registers again next time, and finds itself. + } +} + +function browserId(): string { + const stored = readStorage(BROWSER_ID_KEY); + if (stored) return stored; + const id = randomToken(); + writeStorage(BROWSER_ID_KEY, id); + return id; +} + +/** What the devices list calls this browser, such as "Firefox on macOS". */ +function browserDeviceName(): string { + const parser = Bowser.getParser(navigator.userAgent); + const browser = parser.getBrowserName() || "Browser"; + const os = parser.getOSName(); + return os ? `${browser} on ${os}` : browser; +} + +/** The device this browser registered as for `userId`, if it has. */ +export function cachedBrowserDeviceId(userId: number): string | null { + return readStorage(`${DEVICE_KEY_PREFIX}${userId}`); +} + +/** + * The sync device this browser is for `userId`, registered again on each call + * so a device removed anywhere comes back; the cached id serves when offline. + * + * Returns: + * The device id, or null when the account cannot register devices. + */ +export async function browserDeviceId(userId: number): Promise { + try { + const { data } = await syncApi.registerDevice({ + name: browserDeviceName(), + platform: "Web", + client: "web", + hostname: browserId(), + sync_mode: "api", + allow_existing: true, + }); + writeStorage(`${DEVICE_KEY_PREFIX}${userId}`, data.device_id); + return data.device_id; + } catch (error) { + if (isAxiosError(error) && error.response?.status === 403) return null; + const cached = cachedBrowserDeviceId(userId); + if (cached && isAxiosError(error) && !error.response) return cached; + throw error; + } +} diff --git a/frontend/src/v2/utils/saveSync/hash.test.ts b/frontend/src/v2/utils/saveSync/hash.test.ts new file mode 100644 index 000000000..4e649ef86 --- /dev/null +++ b/frontend/src/v2/utils/saveSync/hash.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { saveContentHash } from "./hash"; + +// Built and hashed by the backend's `hash_zip_contents`, so the two must agree. +const ZIP_BASE64 = + "UEsDBBQAAAAIAAeGQ11gCE0wDQAAAFAAAAAKAAAAQy9TQVZFLkRBVCsoyk8vSi0uLqASDQBQSwMEFAAAAAgAB4ZDXQAAAAACAAAAAAAAAAIAAABDLwMAUEsDBBQAAAAIAAeGQ102gjueCAAAAAYAAAAFAAAAQS5UWFTLOLwyJycfAFBLAQIUAxQAAAAIAAeGQ11gCE0wDQAAAFAAAAAKAAAAAAAAAAAAAACAAQAAAABDL1NBVkUuREFUUEsBAhQDFAAAAAgAB4ZDXQAAAAACAAAAAAAAAAIAAAAAAAAAAAAQAP1BNQAAAEMvUEsBAhQDFAAAAAgAB4ZDXTaCO54IAAAABgAAAAUAAAAAAAAAAAAAAIABVwAAAEEuVFhUUEsFBgAAAAADAAMAmwAAAIIAAAAAAA=="; + +function fromBase64(value: string): Uint8Array { + return Uint8Array.from(atob(value), (char) => char.charCodeAt(0)); +} + +describe("saveContentHash", () => { + it("hashes a plain file as its md5", () => { + expect(saveContentHash(new Uint8Array([83, 82, 65, 77, 0, 1, 2]))).toBe( + "655aa3ec09bb48b00face19877d6d50a", + ); + }); + + it("hashes a zip by its entries, as the server does", () => { + expect(saveContentHash(fromBase64(ZIP_BASE64))).toBe( + "c11dca62d5479918ad24ea47cd8370b6", + ); + }); + + it("falls back to the md5 of a file that only resembles a zip", () => { + const bytes = new Uint8Array([1, 2, 0x50, 0x4b, 0x05, 0x06, 3]); + expect(saveContentHash(bytes)).toMatch(/^[0-9a-f]{32}$/); + }); +}); diff --git a/frontend/src/v2/utils/saveSync/hash.ts b/frontend/src/v2/utils/saveSync/hash.ts new file mode 100644 index 000000000..cca837b91 --- /dev/null +++ b/frontend/src/v2/utils/saveSync/hash.ts @@ -0,0 +1,53 @@ +import { unzipSync } from "fflate"; +import SparkMD5 from "spark-md5"; + +const EOCD_SIGNATURE = [0x50, 0x4b, 0x05, 0x06]; +// The end-of-central-directory record sits within its own size plus the +// longest possible archive comment of the end, which is where Python looks. +const EOCD_SEARCH_BYTES = 22 + 0xffff; + +function md5(bytes: Uint8Array): string { + const whole = + bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength; + const hash = new SparkMD5.ArrayBuffer(); + hash.append((whole ? bytes.buffer : bytes.slice().buffer) as ArrayBuffer); + return hash.end(); +} + +function looksLikeZip(bytes: Uint8Array): boolean { + const start = Math.max(0, bytes.length - EOCD_SEARCH_BYTES); + for (let i = bytes.length - 4; i >= start; i--) { + if (EOCD_SIGNATURE.every((byte, offset) => bytes[i + offset] === byte)) + return true; + } + return false; +} + +/** The hash the server stores for a save, so identical files negotiate as such. */ +export function saveContentHash(bytes: Uint8Array): string { + // A zip hashes by its entries, as `hash_zip_contents` does. + if (looksLikeZip(bytes)) { + try { + const entries = unzipSync(bytes); + const lines = Object.keys(entries) + .filter((name) => !name.endsWith("/")) + .sort() + .map((name) => `${name}:${md5(entries[name]!)}`); + return SparkMD5.hash(lines.join("\n")); + } catch { + // Not a readable archive after all. + } + } + return md5(bytes); +} + +/** Byte equality, cheaper than hashing both sides. */ +export function bytesEqual( + a: Uint8Array | null, + b: Uint8Array | null, +): boolean { + if (!a || !b) return a === b; + if (a.byteLength !== b.byteLength) return false; + for (let i = 0; i < a.byteLength; i++) if (a[i] !== b[i]) return false; + return true; +} diff --git a/frontend/src/v2/utils/saveSync/index.test.ts b/frontend/src/v2/utils/saveSync/index.test.ts new file mode 100644 index 000000000..d3d7e5e25 --- /dev/null +++ b/frontend/src/v2/utils/saveSync/index.test.ts @@ -0,0 +1,367 @@ +import { AxiosError, AxiosHeaders } from "axios"; +import "fake-indexeddb/auto"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { SyncOperationSchema } from "@/__generated__"; +import { saveContentHash } from "./hash"; +import { DeviceSaveSync, type PlayerSaveFile } from "./index"; +import { listLocalSaves } from "./localSaves"; + +const mocks = vi.hoisted(() => ({ + browserDeviceId: vi.fn(), + negotiate: vi.fn(), + completeSession: vi.fn(), + downloadSave: vi.fn(), + confirmDownloaded: vi.fn(), + uploadSaves: vi.fn(), + updateSave: vi.fn(), + sendSaveOnUnload: vi.fn(), +})); + +vi.mock("./browserDevice", () => ({ browserDeviceId: mocks.browserDeviceId })); + +vi.mock("@/services/api/sync", () => ({ + default: { + negotiate: mocks.negotiate, + completeSession: mocks.completeSession, + downloadSave: mocks.downloadSave, + confirmDownloaded: mocks.confirmDownloaded, + }, +})); + +vi.mock("@/services/api/save", () => ({ + default: { + uploadSaves: mocks.uploadSaves, + updateSave: mocks.updateSave, + sendSaveOnUnload: mocks.sendSaveOnUnload, + }, +})); + +const ROM = { id: 5, fs_name_no_ext: "Yume Nikki" }; +let userId = 0; + +function httpError(status: number): AxiosError { + return new AxiosError("failed", "ERR", undefined, undefined, { + status, + statusText: "", + data: null, + headers: {}, + config: { headers: new AxiosHeaders() }, + }); +} + +function playerSave( + slot: string, + content: number, + updatedAt = 1000, +): PlayerSaveFile { + return { + slot, + fileName: `${slot}.lsd`, + bytes: new Uint8Array([content]), + updatedAt, + }; +} + +function operation( + action: SyncOperationSchema["action"], + overrides: Partial = {}, +): SyncOperationSchema { + return { + action, + rom_id: ROM.id, + slot: "Save01", + file_name: "Save01 [2026-01-01_00-00-00].lsd", + reason: "", + ...overrides, + }; +} + +function negotiated(operations: SyncOperationSchema[]) { + mocks.negotiate.mockResolvedValueOnce({ + data: { session_id: 9, operations }, + }); +} + +function sync(): DeviceSaveSync { + return new DeviceSaveSync(ROM, userId, "easyrpg"); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => undefined); + // A fresh account per test keeps the shared fake database apart. + userId += 1; + mocks.browserDeviceId.mockResolvedValue("device-1"); + mocks.completeSession.mockResolvedValue({}); + mocks.confirmDownloaded.mockResolvedValue({}); + mocks.uploadSaves.mockResolvedValue([{ status: "fulfilled", value: {} }]); +}); + +describe("DeviceSaveSync.prepare", () => { + it("downloads what the server holds and confirms it", async () => { + negotiated([ + operation("download", { + save_id: 3, + server_updated_at: "2026-01-01T00:00:00Z", + }), + ]); + mocks.downloadSave.mockResolvedValue({ data: new Uint8Array([7]).buffer }); + + const saves = await sync().prepare(); + + expect(mocks.negotiate).toHaveBeenCalledWith({ + deviceId: "device-1", + romIds: [ROM.id], + restoreUnlisted: true, + emulators: ["easyrpg"], + saves: [], + }); + expect(saves.map((save) => [save.slot, [...save.bytes]])).toEqual([ + ["Save01", [7]], + ]); + const hash = saveContentHash(new Uint8Array([7])); + expect(mocks.confirmDownloaded).toHaveBeenCalledWith({ + saveId: 3, + deviceId: "device-1", + contentHash: hash, + }); + expect(mocks.completeSession).toHaveBeenCalledWith(9, { + operations_completed: 1, + operations_failed: 0, + }); + }); + + it("uploads a save the player holds that the server lacks", async () => { + negotiated([operation("upload")]); + + await sync().prepare([playerSave("Save01", 1)]); + + expect(mocks.negotiate.mock.calls[0]![0].saves).toEqual([ + expect.objectContaining({ + slot: "Save01", + file_name: "Save01.lsd", + content_hash: saveContentHash(new Uint8Array([1])), + file_size_bytes: 1, + }), + ]); + expect(mocks.uploadSaves).toHaveBeenCalledWith( + expect.objectContaining({ + deviceId: "device-1", + slot: "Save01", + sessionId: 9, + overwrite: true, + autocleanup: true, + }), + ); + const [held] = await listLocalSaves(userId, ROM.id); + expect(held!.syncedHash).toBe(held!.hash); + }); + + it("archives this copy and plays the server's on a conflict", async () => { + negotiated([operation("conflict", { save_id: 3 })]); + mocks.downloadSave.mockResolvedValue({ data: new Uint8Array([9]).buffer }); + + const saves = await sync().prepare([playerSave("Save01", 1)]); + + const archived = mocks.uploadSaves.mock.calls[0]![0]; + expect(archived.slot).toBeUndefined(); + expect(archived.savesToUpload[0].saveFile.name).toMatch( + /^Yume Nikki \[.+\]\.lsd$/, + ); + expect([...saves[0]!.bytes]).toEqual([9]); + }); + + it("drops a slot the server emptied", async () => { + negotiated([operation("delete")]); + + const saves = await sync().prepare([playerSave("Save01", 1)]); + + expect(saves).toEqual([]); + expect(await listLocalSaves(userId, ROM.id)).toEqual([]); + }); + + it("leaves a change the server calls a no-op for the next push", async () => { + negotiated([ + operation("no_op", { save_id: 3, server_content_hash: "other" }), + ]); + const saveSync = sync(); + + await saveSync.prepare([playerSave("Save01", 1)]); + await saveSync.push(); + + expect(mocks.uploadSaves).toHaveBeenCalledWith( + expect.objectContaining({ slot: "Save01", overwrite: false }), + ); + }); + + it("calls a no-op on identical bytes synced", async () => { + negotiated([ + operation("no_op", { + save_id: 3, + server_content_hash: saveContentHash(new Uint8Array([1])), + }), + ]); + const saveSync = sync(); + + await saveSync.prepare([playerSave("Save01", 1)]); + await saveSync.push(); + + expect(mocks.uploadSaves).not.toHaveBeenCalled(); + }); + + it("plays what this browser holds when the server is unreachable", async () => { + mocks.negotiate.mockRejectedValueOnce(new Error("offline")); + + const saves = await sync().prepare([playerSave("Save01", 1)]); + + expect(saves.map((save) => save.slot)).toEqual(["Save01"]); + }); + + it("registers again when the device was removed", async () => { + mocks.negotiate.mockRejectedValueOnce(httpError(404)); + mocks.browserDeviceId.mockResolvedValueOnce("gone"); + mocks.browserDeviceId.mockResolvedValueOnce("device-2"); + negotiated([]); + + await sync().prepare(); + + expect(mocks.browserDeviceId).toHaveBeenCalledTimes(2); + expect(mocks.negotiate.mock.calls[1]![0].deviceId).toBe("device-2"); + }); +}); + +describe("DeviceSaveSync.push", () => { + it("uploads only what changed, guarded against other devices", async () => { + negotiated([]); + const saveSync = sync(); + await saveSync.prepare(); + + await saveSync.capture([playerSave("Save01", 1), playerSave("Save02", 2)]); + expect(await saveSync.push()).toBe(true); + expect(await saveSync.push()).toBe(true); + + expect(mocks.uploadSaves).toHaveBeenCalledTimes(2); + expect(mocks.uploadSaves.mock.calls[0]![0]).toMatchObject({ + overwrite: false, + deviceId: "device-1", + }); + }); + + it("archives a save another device wrote over meanwhile", async () => { + negotiated([]); + const saveSync = sync(); + await saveSync.prepare(); + mocks.uploadSaves.mockResolvedValueOnce([ + { status: "rejected", reason: httpError(409) }, + ]); + + await saveSync.capture([playerSave("Save01", 1)]); + + expect(await saveSync.push()).toBe(true); + expect(mocks.uploadSaves).toHaveBeenCalledTimes(2); + expect(mocks.uploadSaves.mock.calls[1]![0].slot).toBeUndefined(); + }); + + it("keeps updating that one archive for the rest of the session", async () => { + negotiated([]); + const saveSync = sync(); + await saveSync.prepare(); + mocks.uploadSaves + .mockResolvedValueOnce([{ status: "rejected", reason: httpError(409) }]) + .mockResolvedValueOnce([{ status: "fulfilled", value: { id: 42 } }]); + mocks.updateSave.mockResolvedValue({ data: { id: 42 } }); + await saveSync.capture([playerSave("Save01", 1)]); + await saveSync.push(); + + await saveSync.capture([playerSave("Save01", 2, 2000)]); + expect(await saveSync.push()).toBe(true); + saveSync.captureOnUnload([playerSave("Save01", 3, 3000)]); + + expect(mocks.uploadSaves).toHaveBeenCalledTimes(2); + expect(mocks.updateSave).toHaveBeenCalledWith( + expect.objectContaining({ + save: { id: 42 }, + contentHash: saveContentHash(new Uint8Array([2])), + }), + ); + expect(mocks.sendSaveOnUnload).toHaveBeenCalledWith( + expect.objectContaining({ save: { id: 42 } }), + ); + }); + + it("keeps a save captured while the previous one uploaded", async () => { + negotiated([]); + const saveSync = sync(); + await saveSync.prepare(); + let finishUpload: () => void = () => undefined; + mocks.uploadSaves.mockReturnValueOnce( + new Promise((resolve) => { + finishUpload = () => resolve([{ status: "fulfilled", value: {} }]); + }), + ); + + await saveSync.capture([playerSave("Save01", 1)]); + const pushing = saveSync.push(); + await vi.waitFor(() => expect(mocks.uploadSaves).toHaveBeenCalled()); + await saveSync.capture([playerSave("Save01", 2, 2000)]); + finishUpload(); + await pushing; + + const [held] = await listLocalSaves(userId, ROM.id); + expect([...held!.bytes]).toEqual([2]); + expect(held!.syncedHash).toBe(saveContentHash(new Uint8Array([1]))); + }); + + it("runs a push after the one in flight, without uploading twice", async () => { + negotiated([]); + const saveSync = sync(); + await saveSync.prepare(); + let finishUpload: () => void = () => undefined; + mocks.uploadSaves.mockReturnValueOnce( + new Promise((resolve) => { + finishUpload = () => resolve([{ status: "fulfilled", value: {} }]); + }), + ); + + await saveSync.capture([playerSave("Save01", 1)]); + const first = saveSync.push(); + await vi.waitFor(() => expect(mocks.uploadSaves).toHaveBeenCalled()); + const second = saveSync.push(); + finishUpload(); + + expect(await Promise.all([first, second])).toEqual([true, true]); + expect(mocks.uploadSaves).toHaveBeenCalledTimes(1); + }); + + it("sends what the player wrote as the page goes away", async () => { + negotiated([]); + const saveSync = sync(); + await saveSync.prepare(); + + saveSync.captureOnUnload([playerSave("Save01", 1)]); + + expect(mocks.sendSaveOnUnload).toHaveBeenCalledWith( + expect.objectContaining({ + deviceId: "device-1", + slot: "Save01", + contentHash: saveContentHash(new Uint8Array([1])), + overwrite: false, + }), + ); + await vi.waitFor(async () => + expect(await listLocalSaves(userId, ROM.id)).toHaveLength(1), + ); + }); + + it("keeps saves in the browser when the account cannot sync", async () => { + mocks.browserDeviceId.mockResolvedValue(null); + const saveSync = sync(); + await saveSync.prepare(); + + await saveSync.capture([playerSave("Save01", 1)]); + + expect(await saveSync.push()).toBe(true); + expect(mocks.negotiate).not.toHaveBeenCalled(); + expect(mocks.uploadSaves).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/v2/utils/saveSync/index.ts b/frontend/src/v2/utils/saveSync/index.ts new file mode 100644 index 000000000..a32612245 --- /dev/null +++ b/frontend/src/v2/utils/saveSync/index.ts @@ -0,0 +1,365 @@ +import { isAxiosError } from "axios"; +import type { SaveSchema, SyncOperationSchema } from "@/__generated__"; +import saveApi from "@/services/api/save"; +import syncApi from "@/services/api/sync"; +import { isSlotConflict, uploadArchivedSave } from "@/services/pending-asset"; +import { browserDeviceId } from "./browserDevice"; +import { bytesEqual, saveContentHash } from "./hash"; +import { + deleteLocalSave, + listLocalSaves, + putLocalSave, + type LocalSave, +} from "./localSaves"; + +export type { LocalSave } from "./localSaves"; + +/** How often a player whose storage cannot signal a write is polled for saves. */ +export const PLAYER_SAVE_POLL_MS = 5000; + +export interface SaveSyncRom { + id: number; + fs_name_no_ext: string; +} + +/** A save as a player holds it, before it becomes this browser's copy. */ +export interface PlayerSaveFile { + slot: string; + fileName: string; + bytes: Uint8Array; + updatedAt: number; +} + +function saveFileOf(save: LocalSave): File { + return new File([save.bytes as BlobPart], save.fileName, { + type: "application/octet-stream", + }); +} + +function extensionOf(fileName: string): string { + const dot = fileName.lastIndexOf("."); + return dot > 0 ? fileName.slice(dot) : ""; +} + +function statusOf(error: unknown): number | undefined { + return isAxiosError(error) ? error.response?.status : undefined; +} + +/** + * Keeps one game's saves in this browser in step with the server, as a sync + * device: negotiated at launch, then pushed as the player writes them. + */ +export class DeviceSaveSync { + private readonly rom: SaveSyncRom; + private readonly userId: number; + private readonly emulator: string; + private readonly saves = new Map(); + // Null until a negotiation succeeds; the saves then stay in this browser. + private deviceId: string | null = null; + // Pushes run one after another, so two never upload the same change. + private pushQueue: Promise = Promise.resolve(); + // Slots another device took this session; their later writes update one + // archive in place, and the next launch settles the slot. + private readonly archives = new Map(); + + constructor(rom: SaveSyncRom, userId: number, emulator: string) { + this.rom = rom; + this.userId = userId; + this.emulator = emulator; + } + + /** + * Sync before a launch, folding in what the player already holds. + * + * Args: + * existing: Saves found in the player's own storage, kept when newer. + * + * Returns: + * The saves to hand the player, synced or, when offline, as held here. + */ + async prepare(existing: PlayerSaveFile[] = []): Promise { + for (const save of await listLocalSaves(this.userId, this.rom.id)) { + this.saves.set(save.slot, save); + } + await this.capture(existing); + try { + await this.negotiate(); + } catch (error) { + console.error("[Save sync] Negotiation failed", error); + } + return [...this.saves.values()]; + } + + /** Keep what the player wrote, for the next push. */ + async capture(files: PlayerSaveFile[]): Promise { + await Promise.all(this.track(files).map((save) => putLocalSave(save))); + } + + /** Capture and send what the player wrote while the page goes away. */ + captureOnUnload(files: PlayerSaveFile[]): void { + const changed = this.track(files); + // Sent first: a closing page may not live to finish an IndexedDB write. + this.pushOnUnload(); + for (const save of changed) void putLocalSave(save).catch(() => undefined); + } + + /** + * Upload every save that changed since the server last held it. + * + * Returns: + * False when one failed to upload; it is retried on the next push. + */ + push(): Promise { + const run = this.pushQueue.then(() => this.uploadChanged()); + this.pushQueue = run; + return run; + } + + private async uploadChanged(): Promise { + if (!this.deviceId) return true; + try { + await Promise.all( + this.changed().map((save) => this.upload(save, { overwrite: false })), + ); + return true; + } catch (error) { + console.error("[Save sync] Upload failed", error); + return false; + } + } + + /** Send the changed saves while the page goes away. */ + pushOnUnload(): void { + if (!this.deviceId) return; + for (const save of this.changed()) { + saveApi.sendSaveOnUnload({ + rom: this.rom, + emulator: save.emulator, + deviceId: this.deviceId, + slot: save.slot, + contentHash: save.hash, + autocleanup: true, + // A refused copy stays in this browser for the next launch to settle. + overwrite: false, + save: this.archives.get(save.slot) ?? null, + saveFile: saveFileOf(save), + }); + } + } + + // Takes the changed files in memory at once, returning them for storing. + private track(files: PlayerSaveFile[]): LocalSave[] { + const changed: LocalSave[] = []; + for (const file of files) { + const held = this.saves.get(file.slot); + if (held && bytesEqual(held.bytes, file.bytes)) continue; + const hash = saveContentHash(file.bytes); + if (held?.hash === hash) continue; + const save: LocalSave = { + userId: this.userId, + romId: this.rom.id, + slot: file.slot, + fileName: file.fileName, + emulator: this.emulator, + bytes: file.bytes, + updatedAt: file.updatedAt, + hash, + syncedHash: held?.syncedHash ?? null, + }; + this.saves.set(save.slot, save); + changed.push(save); + } + return changed; + } + + private changed(): LocalSave[] { + return [...this.saves.values()].filter( + (save) => save.hash !== save.syncedHash, + ); + } + + // Held in memory before the store write, so a capture landing meanwhile + // builds on this copy instead of being overwritten by it. + private async remember(save: LocalSave) { + this.saves.set(save.slot, save); + await putLocalSave(save); + } + + private async negotiate() { + let deviceId = await browserDeviceId(this.userId); + if (!deviceId) return; + const request = (id: string) => + syncApi.negotiate({ + deviceId: id, + romIds: [this.rom.id], + // A browser never deletes a save itself, so one it no longer holds was lost. + restoreUnlisted: true, + // Browser saves load only in the browser player that wrote them. + emulators: [this.emulator], + saves: [...this.saves.values()].map((save) => ({ + rom_id: save.romId, + file_name: save.fileName, + slot: save.slot, + emulator: save.emulator, + content_hash: save.hash, + updated_at: new Date(save.updatedAt).toISOString(), + file_size_bytes: save.bytes.byteLength, + })), + }); + + let response; + try { + response = await request(deviceId); + } catch (error) { + // Sync turned off for this browser keeps its saves here. + if (statusOf(error) === 400) return; + if (statusOf(error) !== 404) throw error; + // The device was removed, so this browser registers again. + deviceId = await browserDeviceId(this.userId); + if (!deviceId) return; + response = await request(deviceId); + } + this.deviceId = deviceId; + + const { session_id: sessionId, operations } = response.data; + let completed = 0; + let failed = 0; + for (const operation of operations) { + if (operation.rom_id !== this.rom.id || !operation.slot) continue; + try { + await this.apply(operation, operation.slot, sessionId); + completed += 1; + } catch (error) { + failed += 1; + console.error(`[Save sync] ${operation.action} failed`, error); + } + } + void syncApi + .completeSession(sessionId, { + operations_completed: completed, + operations_failed: failed, + }) + .catch(() => undefined); + } + + private async apply( + operation: SyncOperationSchema, + slot: string, + sessionId: number, + ) { + const held = this.saves.get(slot); + switch (operation.action) { + case "upload": + if (held) await this.upload(held, { overwrite: true, sessionId }); + return; + case "download": + await this.download(operation, slot, sessionId); + return; + case "conflict": + // Both sides changed: the server's copy is played, this one archived. + if (held) await this.archive(held); + await this.download(operation, slot, sessionId); + return; + case "delete": + await deleteLocalSave(this.userId, this.rom.id, slot); + this.saves.delete(slot); + return; + case "no_op": + // A no-op on differing bytes (clock skew, an untracked save) leaves + // the change unsynced, for the next push. + if ( + held && + held.syncedHash !== held.hash && + operation.server_content_hash === held.hash + ) + await this.markSynced(held); + } + } + + private async download( + operation: SyncOperationSchema, + slot: string, + sessionId: number, + ) { + const saveId = operation.save_id; + const deviceId = this.deviceId; + if (saveId == null || !deviceId) return; + const { data } = await syncApi.downloadSave({ + saveId, + deviceId, + sessionId, + }); + const bytes = new Uint8Array(data); + const hash = saveContentHash(bytes); + await this.remember({ + userId: this.userId, + romId: this.rom.id, + slot, + fileName: operation.file_name, + emulator: operation.emulator ?? this.emulator, + bytes, + updatedAt: Date.parse(operation.server_updated_at ?? "") || Date.now(), + hash, + syncedHash: hash, + }); + await syncApi + .confirmDownloaded({ saveId, deviceId, contentHash: hash }) + .catch((error: unknown) => { + console.error("[Save sync] Download confirmation failed", error); + }); + } + + private async upload( + save: LocalSave, + { overwrite, sessionId }: { overwrite: boolean; sessionId?: number }, + ) { + const archived = this.archives.get(save.slot); + if (archived) { + const { data } = await saveApi.updateSave({ + save: archived, + saveFile: saveFileOf(save), + deviceId: this.deviceId ?? undefined, + contentHash: save.hash, + }); + this.archives.set(save.slot, data); + await this.markSynced(save); + return; + } + const [result] = await saveApi.uploadSaves({ + rom: this.rom, + emulator: save.emulator, + deviceId: this.deviceId ?? undefined, + slot: save.slot, + contentHash: save.hash, + sessionId, + overwrite, + autocleanup: true, + savesToUpload: [{ saveFile: saveFileOf(save) }], + }); + // Another device wrote the slot since this one last synced. + if (isSlotConflict(result)) { + this.archives.set(save.slot, await this.archive(save)); + } else if (result?.status !== "fulfilled") throw result?.reason; + await this.markSynced(save); + } + + // A newer capture may have landed while `save` uploaded, so only the hash + // the server now holds is recorded against it. + private async markSynced(save: LocalSave) { + const current = this.saves.get(save.slot) ?? save; + await this.remember({ ...current, syncedHash: save.hash }); + } + + private async archive(save: LocalSave): Promise { + const result = await uploadArchivedSave({ + rom: this.rom, + emulator: save.emulator, + deviceId: this.deviceId ?? undefined, + capturedAt: new Date(save.updatedAt), + bytes: save.bytes, + extension: extensionOf(save.fileName), + }); + if (result?.status !== "fulfilled") throw result?.reason; + return result.value; + } +} diff --git a/frontend/src/v2/utils/saveSync/localSaves.test.ts b/frontend/src/v2/utils/saveSync/localSaves.test.ts new file mode 100644 index 000000000..ce9e7aaeb --- /dev/null +++ b/frontend/src/v2/utils/saveSync/localSaves.test.ts @@ -0,0 +1,49 @@ +import "fake-indexeddb/auto"; +import { describe, expect, it } from "vitest"; +import { + deleteLocalSave, + listLocalSaves, + putLocalSave, + type LocalSave, +} from "./localSaves"; + +function localSave(overrides: Partial = {}): LocalSave { + return { + userId: 1, + romId: 10, + slot: "autosave", + fileName: "game.srm", + emulator: "easyrpg", + bytes: new Uint8Array([1, 2, 3]), + updatedAt: 1000, + hash: "abc", + syncedHash: null, + ...overrides, + }; +} + +describe("local saves", () => { + it("keeps one copy per slot, apart for each account and rom", async () => { + await putLocalSave(localSave({ slot: "Save01" })); + await putLocalSave(localSave({ slot: "Save01", hash: "newer" })); + await putLocalSave(localSave({ slot: "Save02" })); + await putLocalSave(localSave({ userId: 2, slot: "Save01" })); + await putLocalSave(localSave({ romId: 11, slot: "Save01" })); + + const saves = await listLocalSaves(1, 10); + + expect(saves.map((save) => [save.slot, save.hash])).toEqual([ + ["Save01", "newer"], + ["Save02", "abc"], + ]); + expect(saves[0]!.bytes).toEqual(new Uint8Array([1, 2, 3])); + }); + + it("forgets a deleted slot", async () => { + await putLocalSave(localSave({ romId: 20 })); + + await deleteLocalSave(1, 20, "autosave"); + + expect(await listLocalSaves(1, 20)).toEqual([]); + }); +}); diff --git a/frontend/src/v2/utils/saveSync/localSaves.ts b/frontend/src/v2/utils/saveSync/localSaves.ts new file mode 100644 index 000000000..a34267169 --- /dev/null +++ b/frontend/src/v2/utils/saveSync/localSaves.ts @@ -0,0 +1,72 @@ +// Each RomM account's copy of what its web players saved, per rom and slot, +// so device sync compares one shape whatever the player's own storage. +import { inTransaction, openDb, settle } from "@/v2/utils/idb"; + +const DB_NAME = "romm-saves"; +const DB_VERSION = 1; +const STORE_NAME = "saves"; +const USER_ROM_INDEX = "user-rom"; + +export interface LocalSave { + userId: number; + romId: number; + slot: string; + fileName: string; + emulator: string; + bytes: Uint8Array; + /** When the player wrote this copy, or the server's time for a download. */ + updatedAt: number; + hash: string; + /** The hash the server last held for the slot, so a push sends only changes. */ + syncedHash: string | null; +} + +function openDatabase(): Promise { + return openDb( + DB_NAME, + DB_VERSION, + (request) => { + const store = request.result.createObjectStore(STORE_NAME, { + keyPath: ["userId", "romId", "slot"], + }); + store.createIndex(USER_ROM_INDEX, ["userId", "romId"]); + }, + "Save sync storage", + ); +} + +async function withStore( + mode: IDBTransactionMode, + run: (store: IDBObjectStore) => Promise, +): Promise { + return inTransaction(await openDatabase(), STORE_NAME, mode, run); +} + +export function listLocalSaves( + userId: number, + romId: number, +): Promise { + return withStore("readonly", (store) => + settle( + store.index(USER_ROM_INDEX).getAll([userId, romId]) as IDBRequest< + LocalSave[] + >, + ), + ); +} + +export function putLocalSave(save: LocalSave): Promise { + return withStore("readwrite", async (store) => { + await settle(store.put(save)); + }); +} + +export function deleteLocalSave( + userId: number, + romId: number, + slot: string, +): Promise { + return withStore("readwrite", async (store) => { + await settle(store.delete([userId, romId, slot])); + }); +} diff --git a/frontend/src/v2/utils/saveSync/retryBackoff.test.ts b/frontend/src/v2/utils/saveSync/retryBackoff.test.ts new file mode 100644 index 000000000..2b31399e3 --- /dev/null +++ b/frontend/src/v2/utils/saveSync/retryBackoff.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + createRetryBackoff, + RETRY_BACKOFF_MAX_MS, + RETRY_BACKOFF_MIN_MS, +} from "./retryBackoff"; + +describe("createRetryBackoff", () => { + let clock = 0; + const backoff = () => createRetryBackoff(() => clock); + + beforeEach(() => { + clock = 0; + }); + + it("tries straight away until something has failed", () => { + expect(backoff().ready()).toBe(true); + }); + + // Once a second only hammers a server that keeps saying no. + it("doubles the wait after each failure, up to a cap", () => { + const retry = backoff(); + const waits: number[] = []; + for (let i = 0; i < 6; i++) { + retry.failed(); + const start = clock; + while (!retry.ready()) clock += 1_000; + waits.push(clock - start); + } + + expect(waits).toEqual([ + RETRY_BACKOFF_MIN_MS, + 4_000, + 8_000, + 16_000, + RETRY_BACKOFF_MAX_MS, + RETRY_BACKOFF_MAX_MS, + ]); + }); + + it("starts over once an upload lands or the server is back", () => { + const retry = backoff(); + retry.failed(); + retry.failed(); + expect(retry.ready()).toBe(false); + + retry.reset(); + expect(retry.ready()).toBe(true); + + retry.failed(); + clock += RETRY_BACKOFF_MIN_MS; + expect(retry.ready()).toBe(true); + }); +}); diff --git a/frontend/src/v2/utils/saveSync/retryBackoff.ts b/frontend/src/v2/utils/saveSync/retryBackoff.ts new file mode 100644 index 000000000..717ce7339 --- /dev/null +++ b/frontend/src/v2/utils/saveSync/retryBackoff.ts @@ -0,0 +1,24 @@ +// A poll re-offers a failed upload every tick, which only hammers a server +// that is refusing it or on its way down. +export const RETRY_BACKOFF_MIN_MS = 2_000; +export const RETRY_BACKOFF_MAX_MS = 30_000; + +/** Spaces out the retries of a failing upload, doubling the wait up to a cap. */ +export function createRetryBackoff(now: () => number = Date.now) { + let delay = 0; + let retryAt = 0; + return { + ready: (): boolean => now() >= retryAt, + failed() { + delay = Math.min( + Math.max(delay * 2, RETRY_BACKOFF_MIN_MS), + RETRY_BACKOFF_MAX_MS, + ); + retryAt = now() + delay; + }, + reset() { + delay = 0; + retryAt = 0; + }, + }; +} diff --git a/frontend/src/v2/views/Player/EasyRpg.test.ts b/frontend/src/v2/views/Player/EasyRpg.test.ts index fe5d357f4..87db389dd 100644 --- a/frontend/src/v2/views/Player/EasyRpg.test.ts +++ b/frontend/src/v2/views/Player/EasyRpg.test.ts @@ -1,20 +1,30 @@ import { flushPromises, mount, type VueWrapper } from "@vue/test-utils"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { saveSyncMocks } from "@/test-utils/deviceSaveSync"; import EasyRpg from "./EasyRpg.vue"; const mocks = vi.hoisted(() => ({ + confirm: vi.fn(), + deleteSaves: vi.fn(), flushPlaySession: vi.fn(), getRom: vi.fn(), playSessionStart: vi.fn(), + readSaves: vi.fn(), + routeLeaveGuard: null as ((to: { fullPath: string }) => unknown) | null, routerReplace: vi.fn(() => Promise.resolve()), + seededRom: null as unknown, setPlaying: vi.fn(), setStageActive: vi.fn(), + snackbarError: vi.fn(), + writeSaves: vi.fn(), })); vi.mock("vue-i18n"); vi.mock("vue-router", () => ({ - onBeforeRouteLeave: vi.fn(), + onBeforeRouteLeave: (guard: (to: { fullPath: string }) => unknown) => { + mocks.routeLeaveGuard = guard; + }, useRoute: () => ({ params: { rom: "1" } }), useRouter: () => ({ replace: mocks.routerReplace }), })); @@ -39,7 +49,7 @@ vi.mock("@/stores/playing", () => ({ })); vi.mock("@/stores/roms", () => ({ - default: () => ({ getDetailedRom: () => null }), + default: () => ({ getDetailedRom: () => mocks.seededRom }), })); vi.mock("@/v2/components/shared/GameCover.vue", () => ({ @@ -50,6 +60,10 @@ vi.mock("@/v2/composables/useBackgroundArt", () => ({ useBackgroundArt: () => vi.fn(), })); +vi.mock("@/v2/composables/useConfirm", () => ({ + useConfirm: () => mocks.confirm, +})); + vi.mock("@/v2/composables/useFullscreenPref", async () => { const { ref } = await import("vue"); return { useFullscreenPref: () => ({ fullscreenOnPlay: ref(false) }) }; @@ -66,10 +80,23 @@ vi.mock("@/v2/composables/usePlaySession", () => ({ }), })); +vi.mock("@/v2/composables/useSnackbar", () => ({ + useSnackbar: () => ({ error: mocks.snackbarError }), +})); + vi.mock("@/v2/stores/galleryRoms", () => ({ default: () => ({ getRomById: () => null }), })); +vi.mock("@/v2/utils/easyRpgStorage", () => ({ + deleteEasyRpgSaves: mocks.deleteSaves, + easyRpgGameName: (romId: number, userId: number) => `${romId}-${userId}`, + readEasyRpgSaves: mocks.readSaves, + writeEasyRpgSaves: mocks.writeSaves, +})); + +vi.mock("@/v2/utils/saveSync", () => import("@/test-utils/deviceSaveSync")); + const rom = { id: 1, name: "Yume Nikki", @@ -79,10 +106,42 @@ const rom = { rom_user: { status: null }, }; +function playerSave(slot: string, content: number) { + return { + slot, + fileName: `${slot}.lsd`, + bytes: new Uint8Array([content]), + updatedAt: 1000, + }; +} + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => undefined); + mocks.routeLeaveGuard = null; + mocks.seededRom = null; mocks.getRom.mockResolvedValue({ data: rom }); + mocks.readSaves.mockResolvedValue([]); + mocks.writeSaves.mockResolvedValue(undefined); + mocks.deleteSaves.mockResolvedValue(undefined); + saveSyncMocks.prepare.mockResolvedValue([]); + saveSyncMocks.capture.mockResolvedValue(undefined); + saveSyncMocks.push.mockResolvedValue(true); + mocks.confirm.mockResolvedValue(false); }); +afterEach(() => { + vi.useRealTimers(); +}); + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve: (value: T) => void = () => undefined; + const promise = new Promise((settle) => { + resolve = settle; + }); + return { promise, resolve }; +} + function mountView(): VueWrapper { return mount(EasyRpg, { global: { @@ -110,15 +169,46 @@ async function play(): Promise { } describe("EasyRpg", () => { - it("boots the game under a save name scoped to the user", async () => { + it("syncs the player's saves before booting the game", async () => { + const held = [playerSave("Save01", 1), playerSave("Save02", 2)]; + mocks.readSaves.mockResolvedValue(held); + saveSyncMocks.prepare.mockResolvedValue([ + { ...playerSave("Save01", 1) }, + { ...playerSave("Save02", 9) }, + { ...playerSave("Save03", 3) }, + ]); + const wrapper = await play(); + expect(saveSyncMocks.args).toEqual([rom, 7, "easyrpg"]); + expect(mocks.readSaves).toHaveBeenCalledWith("1-7"); + expect(saveSyncMocks.prepare).toHaveBeenCalledWith(held); + const written = mocks.writeSaves.mock.calls[0]!; + expect(written[0]).toBe("1-7"); + expect(written[1].map((save: { slot: string }) => save.slot)).toEqual([ + "Save02", + "Save03", + ]); expect(wrapper.get("iframe").attributes("src")).toBe( "/assets/easyrpg/index.html?game=1-7", ); wrapper.unmount(); }); + it("removes the saves sync deleted from the player", async () => { + mocks.readSaves.mockResolvedValue([ + playerSave("Save01", 1), + playerSave("Save02", 2), + ]); + saveSyncMocks.prepare.mockResolvedValue([playerSave("Save01", 1)]); + + const wrapper = await play(); + + expect(mocks.deleteSaves).toHaveBeenCalledWith("1-7", ["Save02.lsd"]); + expect(wrapper.find("iframe").exists()).toBe(true); + wrapper.unmount(); + }); + it("starts timing the session once the player page loads", async () => { const wrapper = await play(); expect(mocks.playSessionStart).not.toHaveBeenCalled(); @@ -131,21 +221,105 @@ describe("EasyRpg", () => { wrapper.unmount(); }); - it("records the play session and returns to the game on quit", async () => { + it("stays on the start page when the saves cannot be written", async () => { + mocks.writeSaves.mockRejectedValue(new Error("quota")); + saveSyncMocks.prepare.mockResolvedValue([playerSave("Save01", 1)]); + const wrapper = await play(); - await wrapper.get(".r-v2-player__quit").trigger("click"); + expect(mocks.snackbarError).toHaveBeenCalledWith( + "play.easyrpg-saves-load-failed", + ); + expect(wrapper.find("iframe").exists()).toBe(false); + expect(mocks.playSessionStart).not.toHaveBeenCalled(); + wrapper.unmount(); + }); - expect(mocks.flushPlaySession).toHaveBeenCalled(); + it("captures and pushes the latest saves before leaving", async () => { + const wrapper = await play(); + const latest = [playerSave("Save01", 5)]; + mocks.readSaves.mockResolvedValue(latest); + + expect(mocks.routeLeaveGuard?.({ fullPath: "/rom/1" })).toBe(false); + await flushPromises(); + + expect(saveSyncMocks.capture).toHaveBeenCalledWith(latest); + expect(saveSyncMocks.push).toHaveBeenCalled(); + expect(mocks.confirm).not.toHaveBeenCalled(); expect(mocks.routerReplace).toHaveBeenCalledWith("/rom/1"); wrapper.unmount(); }); - it("records the play session when the page goes away", async () => { + it("asks before leaving when a save did not reach the server", async () => { + saveSyncMocks.push.mockResolvedValue(false); + const wrapper = await play(); + + mocks.routeLeaveGuard?.({ fullPath: "/rom/1" }); + await flushPromises(); + + expect(mocks.confirm).toHaveBeenCalledWith( + expect.objectContaining({ title: "play.quit-before-save-synced" }), + ); + expect(mocks.routerReplace).not.toHaveBeenCalled(); + wrapper.unmount(); + }); + + it("waits for the fetched rom before allowing a launch", async () => { + mocks.seededRom = rom; + const fetched = deferred<{ data: typeof rom }>(); + mocks.getRom.mockReturnValue(fetched.promise); + const wrapper = mountView(); + await flushPromises(); + + expect( + wrapper.get(".r-v2-player__play").attributes("disabled"), + ).toBeDefined(); + + fetched.resolve({ data: rom }); + await flushPromises(); + expect( + wrapper.get(".r-v2-player__play").attributes("disabled"), + ).toBeUndefined(); + wrapper.unmount(); + }); + + it("does not start a game the user left while its saves synced", async () => { + const preparing = deferred(); + saveSyncMocks.prepare.mockReturnValue(preparing.promise); + const wrapper = mountView(); + await flushPromises(); + await wrapper.get(".r-v2-player__play").trigger("click"); + + wrapper.unmount(); + preparing.resolve([]); + await flushPromises(); + + expect(mocks.playSessionStart).not.toHaveBeenCalled(); + }); + + it("pushes again on leave when a poll push was still in flight", async () => { + vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] }); + const poll = deferred(); + saveSyncMocks.push.mockReturnValueOnce(poll.promise); + const wrapper = await play(); + + vi.advanceTimersByTime(5000); + await flushPromises(); + mocks.routeLeaveGuard?.({ fullPath: "/rom/1" }); + poll.resolve(true); + await flushPromises(); + + expect(saveSyncMocks.push).toHaveBeenCalledTimes(2); + expect(mocks.routerReplace).toHaveBeenCalledWith("/rom/1"); + wrapper.unmount(); + }); + + it("sends pending saves when the page goes away", async () => { const wrapper = await play(); window.dispatchEvent(new Event("pagehide")); + expect(saveSyncMocks.pushOnUnload).toHaveBeenCalled(); expect(mocks.flushPlaySession).toHaveBeenCalled(); wrapper.unmount(); }); diff --git a/frontend/src/v2/views/Player/EasyRpg.vue b/frontend/src/v2/views/Player/EasyRpg.vue index fa6b5db4a..744e32e7a 100644 --- a/frontend/src/v2/views/Player/EasyRpg.vue +++ b/frontend/src/v2/views/Player/EasyRpg.vue @@ -15,15 +15,25 @@ import romApi from "@/services/api/rom"; import storeAuth from "@/stores/auth"; import type { DetailedRom } from "@/stores/roms"; import PlayerShell from "@/v2/components/Player/PlayerShell.vue"; +import { useDeviceSaveSync } from "@/v2/composables/useDeviceSaveSync"; import { useFullscreenPref } from "@/v2/composables/useFullscreenPref"; import { useIsAlive } from "@/v2/composables/useIsAlive"; import { usePlaySession } from "@/v2/composables/usePlaySession"; import { usePlayerExit } from "@/v2/composables/usePlayerExit"; import { usePlayerFullscreen } from "@/v2/composables/usePlayerFullscreen"; import { usePlayerHero } from "@/v2/composables/usePlayerHero"; +import { useSnackbar } from "@/v2/composables/useSnackbar"; import { usePlayingWhile } from "@/v2/composables/useStageActive"; import { useUnloadGuard } from "@/v2/composables/useUnloadGuard"; import { focusFromInput } from "@/v2/utils/autofocus"; +import { + deleteEasyRpgSaves, + easyRpgGameName, + readEasyRpgSaves, + writeEasyRpgSaves, +} from "@/v2/utils/easyRpgStorage"; +import type { LocalSave } from "@/v2/utils/saveSync"; +import { bytesEqual } from "@/v2/utils/saveSync/hash"; const { t } = useI18n(); const exit = usePlayerExit(); @@ -31,11 +41,15 @@ const alive = useIsAlive(); const authStore = storeAuth(); const { fullscreenOnPlay } = useFullscreenPref(); const playSession = usePlaySession(); +const snackbar = useSnackbar(); const rom = shallowRef(null); +// The seeded rom is only a placeholder; the launch waits for the fetched one. +const romFetched = ref(false); const gameRunning = ref(false); // The game takes the keyboard inside its frame, so app hotkeys stand down. usePlayingWhile(gameRunning); +const preparing = ref(false); const quitting = ref(false); const frame = ref(null); const { enter: enterFullscreen } = usePlayerFullscreen(frame); @@ -45,14 +59,60 @@ const { romId, heroRom, title, platformLabel } = usePlayerHero(rom); // The player keeps saves in a browser database named after the game, so the // user id keeps each RomM account's saves apart. The server ignores it. -const playerSrc = computed( - () => `/assets/easyrpg/index.html?game=${romId}-${authStore.user?.id}`, +const gameName = computed(() => + easyRpgGameName(romId, authStore.user?.id ?? 0), ); +const playerSrc = computed( + () => `/assets/easyrpg/index.html?game=${gameName.value}`, +); + +// The player writes a save to browser storage as soon as the game saves. +const saveSync = useDeviceSaveSync({ + emulator: "easyrpg", + read: () => readEasyRpgSaves(gameName.value), +}); + +// Only the slots whose bytes differ, so the player keeps its own timestamps. +// `saves` began as every slot the player held, so a missing one sync deleted. +async function restoreSaves(game: string, saves: LocalSave[]) { + const playerSaves = await readEasyRpgSaves(game); + const kept = new Set(saves.map((save) => save.slot)); + await deleteEasyRpgSaves( + game, + playerSaves + .filter((save) => !kept.has(save.slot)) + .map((save) => save.fileName), + ); + const held = new Map(playerSaves.map((save) => [save.slot, save.bytes])); + const stale = saves.filter( + (save) => !bytesEqual(held.get(save.slot) ?? null, save.bytes), + ); + await writeEasyRpgSaves(game, stale); +} async function onPlay() { const currentRom = rom.value; - if (!currentRom || authStore.user?.id == null) return; + const userId = authStore.user?.id; + if (!currentRom || userId == null || preparing.value) return; + preparing.value = true; + const game = gameName.value; + try { + const saves = await saveSync.prepare( + currentRom, + await readEasyRpgSaves(game), + ); + if (saves) await restoreSaves(game, saves); + } catch (error) { + console.error("[EasyRPG] Save storage failed", error); + snackbar.error(t("play.easyrpg-saves-load-failed")); + return; + } finally { + preparing.value = false; + } + if (!alive.value) return; + + saveSync.start(); gameRunning.value = true; await nextTick(); @@ -71,15 +131,28 @@ function onFrameLoad() { } } -function leavePlayer(destination: string) { +function teardown() { + saveSync.stop(); + playSession.flush(); +} + +async function leavePlayer(destination: string) { if (quitting.value) return; quitting.value = true; - playSession.flush(); + + if (!(await saveSync.flush())) { + if (!(await saveSync.confirmDiscard())) { + quitting.value = false; + return; + } + } + + teardown(); exit.leave(destination); } function onlyQuit() { - leavePlayer(`/rom/${romId}`); + void leavePlayer(`/rom/${romId}`); } useUnloadGuard(() => gameRunning.value && !quitting.value); @@ -88,13 +161,18 @@ onMounted(async () => { const romResponse = await romApi.getRom({ romId }); if (!alive.value) return; rom.value = romResponse.data; + romFetched.value = true; }); -onBeforeRouteLeave((to) => exit.guard(to)); +onBeforeRouteLeave((to) => { + if (!saveSync.isActive()) return exit.guard(to); + void leavePlayer(to.fullPath); + return false; +}); useEventListener(window, "pagehide", () => playSession.flush()); -onBeforeUnmount(() => playSession.flush()); +onBeforeUnmount(teardown);