mirror of
https://github.com/rommapp/romm.git
synced 2026-10-12 02:21:58 +00:00
Sync saves from every in-browser player as a browser device (#5091)
* feat(player): play RPG Maker 2000/2003 games in the browser with EasyRPG Embed the EasyRPG web player for extracted game folders on the rpg-maker platform. The backend generates the player's index.json from the scanned files and merges in the free EasyRPG RTP under every name the official and fan RTPs give its assets, so games that lean on the RTP find them. In-game saves sync with the user's RomM saves through the player's IndexedDB storage. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * refactor(easyrpg): read the free RTP from a fixed path instead of an env var The full image always keeps the RTP at /var/www/html/assets/easyrpg/rtp, so the path is a constant beside the web path nginx serves it under. The dev container links its copy there at startup. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * fix(easyrpg): code-review fixes for the save sync and asset endpoint - Wait for an in-flight push before the quit push, so the newest save uploads - Keep Play disabled until the fresh rom (and its saves) is fetched - Stop onPlay and the rom fetch from acting after the view unmounts - Leave through usePlayerExit like the other players - Handle a rejected IDB commit when the run itself fails - Track uploads by the browser's file name - Load only visibility and files per asset request, not the full rom - Serve DEV_MODE files as octet-stream, matching nginx - Make the traversal test actually reach the endpoint Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * refactor(easyrpg): simplify pass over the player and asset endpoint - Load only file paths per asset request, and look up RTP folders in O(1) - Reuse RomFile.file_name_for_download and usePlayerFullscreen - Share one dev/prod serve helper for game and RTP files - Derive the save sync's game key from the rom - Have the RTP table tool reuse normalize_name - Make save-module internals private and tidy the prepare/read loops Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * feat(easyrpg): flag RPG Maker game folders on the rom schemas Gallery lists carry no files, so the frontend guessed that every multi-file rom on the rpg-maker platform was playable, including RPG Maker XP/VX/MV folders. Rom now has a deferred `is_easyrpg_game` subquery (an RPG_RT.ldb at the folder root), loaded with the other gallery file stats and derived from loaded files on the with_files path, and the frontend reads it. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * refactor(easyrpg): drop server save sync, keep saves per user in the browser Server sync moves to a follow-up that puts the web players on device sync. Saves now stay in the player's IndexedDB, as js-dos does, with the same note on the start page. The player is launched as `<rom id>-<user id>` so each RomM account sharing a browser profile keeps its own saves; nginx and Vite drop the user id when routing to the backend. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * feat(sync): let a client ask negotiate to restore the saves it lost Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * feat(saves): sync web player saves as a browser device A shared layer for the in-browser players: each browser profile registers as a sync device, keeps every account's saves in its own IndexedDB store, negotiates them with the server at launch (restoring any it lost), and pushes changes with the device's conflict guard. Hashes match the server's, including its zip-entry hash. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * feat(emulatorjs): save as the browser's sync device, with the slot guard EmulatorJS now uploads and downloads as this browser's registered device instead of the IP-based login device, confirms downloads with their hash, and lets a write that continues the slot's head be refused when another device wrote the slot since; that progress is archived and the session keeps writing to the archive. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * feat(settings): list, rename, pause and remove your devices Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * feat(player): sync EasyRPG, js-dos, PICO-8 and Ruffle saves with RomM Each player's own storage is restored from the browser's synced copy before boot and captured back during play and on leave: - EasyRPG: its IDBFS saves, one slot per in-game slot - js-dos: its changes zip through fsChanges pull/push, migrating saves it kept in OPFS before - PICO-8: cartdata files, flushed by loading a blank cart on leave - Ruffle: the game's SharedObjects zipped into one save, kept out of localStorage between plays so accounts don't share them Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * fix(easyrpg): address Greptile review - Start timing the play session once the player page loads - Use object-form props in the view test's button mock - Keep the RTP table tool's docstring to one sentence Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * fix(saves): address Greptile review on web device save sync - Remove EasyRPG save files that sync deleted, so the game cannot load them - Keep the stale-device guard on device sync's page-close uploads - Restore only the Ruffle SharedObjects the current SWF can reach Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * fix(saves): code-review pass on web device save sync Restore master's EasyRPG RTP table path lost in the merge, serialize DeviceSaveSync pushes, only mark a no_op synced when the server hash matches, forget the browser device when it is removed, close a late EasyRPG IndexedDB connection, send content_hash on unload updates, and share the IndexedDB helpers and slot-conflict check. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * refactor(saves): simplify pass on web device save sync Archive conflicts through uploadArchivedSave and isSlotConflict, skip hashing unchanged bytes, share bytesEqual from saveSync/hash, pass restore_unlisted from DeviceSaveSync, use newest() for the EmulatorJS slot head, and trim one-off Ruffle and PICO-8 helpers. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * chore(saves): review-polish pass on web device save sync Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * docs(saves): humanizer pass on web device save sync comments Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * refactor(saves): open every player IndexedDB through openDb The save sync store now rejects when another tab blocks its upgrade instead of waiting forever, as the EasyRPG and pending-asset stores already did. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * feat(sync): let negotiate scope server saves to the client's emulators A browser player's saves load only in that player, so a native save in the same slot must not pair with it or download into it. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * fix(saves): register the browser device on each launch Registration returns the existing device, so calling it every time brings back a device removed from any browser, which EmulatorJS otherwise kept sending as a dead id. The cached id now serves only when offline. DeviceSaveSync also negotiates within its own emulator. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * refactor(player): share web player save sync through useDeviceSaveSync The coalesced polled push, the flush before leaving, the page-close capture and the discard prompt live in one composable, and the player suites share one DeviceSaveSync fake from test-utils. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * fix(saves): update one archive per slot after another device takes it A slot refused mid-session archived every later write as a new save. Later writes now update that archive in place, page-close sends included, and the next launch settles the slot. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * perf(ruffle): skip unchanged polls and upload saves every 5 minutes Polls compare the stored SharedObjects with the last read before zipping them, and keep what changed in the browser, uploading it at most every 5 minutes. Leaving and page close still upload at once. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> * perf(ruffle): sync saves like EmulatorJS, polling every second Polls compare the raw stored strings, so an idle second decodes, zips and hashes nothing. A change uploads once two polls agree on it, and a failing upload backs off from 2 to 30 seconds through the retry backoff EmulatorJS uses, now shared from saveSync. Leaving still uploads at once. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a0fa9d43a8
commit
8a243c71e9
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
Generated
+27
-1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<number> | 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<string> | null);
|
||||
};
|
||||
|
||||
|
||||
@@ -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": "Избраното състояние не можа да бъде заредено. Играта започна отначало.",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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": "選択したステートを読み込めませんでした。ゲームは最初から開始されました。",
|
||||
|
||||
@@ -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}を無効化",
|
||||
|
||||
@@ -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": "선택한 상태를 불러오지 못했습니다. 게임이 처음부터 시작되었습니다.",
|
||||
|
||||
@@ -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} 비활성화",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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": "Не удалось загрузить выбранное состояние. Игра началась заново.",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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ı.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "无法加载所选的存档状态。游戏已重新开始。",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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": "無法載入所選的存檔狀態。遊戲已重新開始。",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"] },
|
||||
|
||||
@@ -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<string[]>("/devices/online");
|
||||
}
|
||||
|
||||
async function updateDevice(id: string, payload: DeviceUpdatePayload) {
|
||||
return api.put<DeviceSchema>(`/devices/${encodeURIComponent(id)}`, payload);
|
||||
}
|
||||
|
||||
async function deleteDevice(id: string) {
|
||||
return api.delete(`/devices/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
export default {
|
||||
fetchDevices,
|
||||
fetchOnlineDeviceIds,
|
||||
updateDevice,
|
||||
deleteDevice,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<UpdateSaveUploadInput>([
|
||||
["saveFile", saveFile],
|
||||
@@ -135,7 +146,7 @@ async function updateSave({
|
||||
]);
|
||||
|
||||
return api.put<SaveSchema>(`/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), {
|
||||
|
||||
@@ -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<DeviceCreateResponse>("/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<SyncNegotiateResponse>("/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<ArrayBuffer>(`/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,
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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<IDBDatabase> {
|
||||
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<unknown>): boolean {
|
||||
export function isSlotConflict(
|
||||
result?: PromiseSettledResult<unknown>,
|
||||
): boolean {
|
||||
return (
|
||||
result?.status === "rejected" &&
|
||||
axios.isAxiosError(result.reason) &&
|
||||
@@ -232,21 +217,36 @@ function isSlotConflict(result?: PromiseSettledResult<unknown>): 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<PromiseSettledResult<unknown> | 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<PromiseSettledResult<SaveSchema> | 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<PromiseSettledResult<SaveSchema> | 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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+5
-1
@@ -7,8 +7,12 @@ export interface JsDosOptions {
|
||||
autoSave: boolean;
|
||||
fullScreen: boolean;
|
||||
fsChanges: {
|
||||
local: boolean;
|
||||
local?: boolean;
|
||||
urlToKey?: (url: string) => Promise<string>;
|
||||
/** Supplies the saved changes in place of browser storage. */
|
||||
pull?: (key: string) => Promise<Uint8Array | null>;
|
||||
/** Receives the changes on each save in place of browser storage; a throw fails the save. */
|
||||
push?: (key: string, changes: Uint8Array) => Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
<RMenuItem
|
||||
v-if="canSeeDevices"
|
||||
:to="{ name: ROUTES.DEVICES }"
|
||||
icon="mdi-devices"
|
||||
:label="t('settings.devices')"
|
||||
@click="open = false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- System -->
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { RBtn, RDialog, RForm, RTextField } from "@v2/lib";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { notBlank } from "@/v2/utils/validation";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: boolean;
|
||||
name: string;
|
||||
busy?: boolean;
|
||||
}>(),
|
||||
{ busy: false },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: boolean): void;
|
||||
(e: "submit", name: string): void;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const draft = ref(props.name);
|
||||
const trimmed = computed(() => draft.value.trim());
|
||||
const rules = [notBlank()];
|
||||
const canSubmit = computed(
|
||||
() => !!trimmed.value && trimmed.value !== props.name && !props.busy,
|
||||
);
|
||||
|
||||
// Reopening resets the field, so a cancelled rename does not carry over.
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (open) draft.value = props.name;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function close(): void {
|
||||
emit("update:modelValue", false);
|
||||
}
|
||||
|
||||
function submit(): void {
|
||||
if (!canSubmit.value) return;
|
||||
emit("submit", trimmed.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RDialog
|
||||
:model-value="modelValue"
|
||||
icon="mdi-pencil-outline"
|
||||
:width="420"
|
||||
cancelable
|
||||
:cancel-disabled="busy"
|
||||
@update:model-value="
|
||||
(v) => {
|
||||
if (!v) close();
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #header>
|
||||
<span>{{ t("settings.device-rename") }}</span>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<RForm @submit="submit">
|
||||
<!-- eslint-disable vuejs-accessibility/no-autofocus -- autofocusing the only field on dialog open is intentional modal UX -->
|
||||
<RTextField
|
||||
v-model="draft"
|
||||
:label="t('common.name')"
|
||||
prefix-label="stacked"
|
||||
:rules="rules"
|
||||
required
|
||||
autofocus
|
||||
/>
|
||||
<!-- eslint-enable vuejs-accessibility/no-autofocus -->
|
||||
</RForm>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<RBtn
|
||||
variant="flat"
|
||||
color="primary"
|
||||
prepend-icon="mdi-check"
|
||||
:loading="busy"
|
||||
:disabled="!canSubmit"
|
||||
@click="submit"
|
||||
>
|
||||
{{ t("common.save") }}
|
||||
</RBtn>
|
||||
</template>
|
||||
</RDialog>
|
||||
</template>
|
||||
@@ -130,6 +130,12 @@ const groups = computed<Group[]>(() => {
|
||||
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"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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<DeviceSaveSyncOptions> = {}) {
|
||||
scope = effectScope();
|
||||
return scope.run(() => useDeviceSaveSync({ emulator: "test", ...options }))!;
|
||||
}
|
||||
|
||||
async function started(options: Partial<DeviceSaveSyncOptions> = {}) {
|
||||
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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<PlayerSaveFile[]>;
|
||||
/** 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<boolean> | 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<LocalSave[] | null> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
return run(false);
|
||||
}
|
||||
|
||||
// A push in flight may have read the saves before the latest one landed.
|
||||
async function flush(): Promise<boolean> {
|
||||
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<void> {
|
||||
await sync?.capture(files);
|
||||
}
|
||||
|
||||
function confirmDiscard(): Promise<boolean> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -64,6 +64,7 @@ export const v2RouteComponents: Partial<Record<RouteName, V2Route>> = {
|
||||
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"),
|
||||
|
||||
@@ -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<IDBDatabase> {
|
||||
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<void>((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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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/<game>/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<IDBDatabase> {
|
||||
return openDb(
|
||||
saveDir(game),
|
||||
IDBFS_VERSION,
|
||||
upgradeSaveDb,
|
||||
"EasyRPG save storage",
|
||||
);
|
||||
}
|
||||
|
||||
async function withSaveStore<T>(
|
||||
game: string,
|
||||
mode: IDBTransactionMode,
|
||||
run: (store: IDBObjectStore) => Promise<T>,
|
||||
): Promise<T> {
|
||||
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<PlayerSaveFile[]> {
|
||||
const prefix = `${saveDir(game)}/`;
|
||||
return withSaveStore(game, "readonly", async (store) => {
|
||||
const [keys, entries] = await Promise.all([
|
||||
settle(store.getAllKeys()),
|
||||
settle(store.getAll() as IDBRequest<IdbfsEntry[]>),
|
||||
]);
|
||||
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<void> {
|
||||
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<void> {
|
||||
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}`)),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/** The result of an IndexedDB request, once it succeeds. */
|
||||
export function settle<T>(request: IDBRequest<T>): Promise<T> {
|
||||
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<IDBDatabase> {
|
||||
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<T>(
|
||||
db: IDBDatabase,
|
||||
storeName: string,
|
||||
mode: IDBTransactionMode,
|
||||
run: (store: IDBObjectStore) => Promise<T>,
|
||||
): Promise<T> {
|
||||
try {
|
||||
const transaction = db.transaction(storeName, mode);
|
||||
const committed = new Promise<void>((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();
|
||||
}
|
||||
}
|
||||
@@ -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<string, Uint8Array>();
|
||||
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]) },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
// Ruffle keeps each SharedObject in localStorage as a base64 `.sol` file, keyed
|
||||
// `<host>/<SWF path or an ancestor>/<name>`, 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<string, Uint8Array>;
|
||||
/** The same SharedObjects as stored, still base64, for a cheap comparison. */
|
||||
export type RuffleStorage = Record<string, string>;
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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}$/);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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> = {},
|
||||
): 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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, LocalSave>();
|
||||
// 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<unknown> = 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<string, SaveSchema>();
|
||||
|
||||
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<LocalSave[]> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
const run = this.pushQueue.then(() => this.uploadChanged());
|
||||
this.pushQueue = run;
|
||||
return run;
|
||||
}
|
||||
|
||||
private async uploadChanged(): Promise<boolean> {
|
||||
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<SaveSchema> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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> = {}): 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<IDBDatabase> {
|
||||
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<T>(
|
||||
mode: IDBTransactionMode,
|
||||
run: (store: IDBObjectStore) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return inTransaction(await openDatabase(), STORE_NAME, mode, run);
|
||||
}
|
||||
|
||||
export function listLocalSaves(
|
||||
userId: number,
|
||||
romId: number,
|
||||
): Promise<LocalSave[]> {
|
||||
return withStore("readonly", (store) =>
|
||||
settle(
|
||||
store.index(USER_ROM_INDEX).getAll([userId, romId]) as IDBRequest<
|
||||
LocalSave[]
|
||||
>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function putLocalSave(save: LocalSave): Promise<void> {
|
||||
return withStore("readwrite", async (store) => {
|
||||
await settle(store.put(save));
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteLocalSave(
|
||||
userId: number,
|
||||
romId: number,
|
||||
slot: string,
|
||||
): Promise<void> {
|
||||
return withStore("readwrite", async (store) => {
|
||||
await settle(store.delete([userId, romId, slot]));
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
let resolve: (value: T) => void = () => undefined;
|
||||
const promise = new Promise<T>((settle) => {
|
||||
resolve = settle;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function mountView(): VueWrapper {
|
||||
return mount(EasyRpg, {
|
||||
global: {
|
||||
@@ -110,15 +169,46 @@ async function play(): Promise<VueWrapper> {
|
||||
}
|
||||
|
||||
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<never[]>();
|
||||
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<boolean>();
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -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<DetailedRom | null>(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<HTMLIFrameElement | null>(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);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -103,7 +181,7 @@ onBeforeUnmount(() => playSession.flush());
|
||||
:title="title"
|
||||
:platform-label="platformLabel"
|
||||
:rom-id="romId"
|
||||
:ready="!!rom"
|
||||
:ready="romFetched && !preparing"
|
||||
:running="gameRunning"
|
||||
:quitting="quitting"
|
||||
@play="onPlay"
|
||||
@@ -111,10 +189,6 @@ onBeforeUnmount(() => playSession.flush());
|
||||
>
|
||||
<template #settings>
|
||||
<RSwitch v-model="fullscreenOnPlay" :label="t('play.full-screen')" />
|
||||
|
||||
<p class="r-v2-easyrpg__save-note">
|
||||
{{ t("play.easyrpg-browser-save-warning") }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template #brand>
|
||||
@@ -139,12 +213,6 @@ onBeforeUnmount(() => playSession.flush());
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.r-v2-easyrpg__save-note {
|
||||
margin: 0;
|
||||
color: var(--r-color-fg-muted);
|
||||
font-size: var(--r-font-size-sm);
|
||||
}
|
||||
|
||||
.r-v2-easyrpg__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { flushPromises, mount, type VueWrapper } from "@vue/test-utils";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { nextTick } from "vue";
|
||||
import { saveSyncMocks } from "@/test-utils/deviceSaveSync";
|
||||
import type { JsDosOptions, JsDosProps } from "@/types/js-dos";
|
||||
import JsDos from "./JsDos.vue";
|
||||
|
||||
@@ -94,6 +95,9 @@ vi.mock("@/v2/stores/galleryRoms", () => ({
|
||||
default: () => ({ getRomById: () => mocks.galleryRom }),
|
||||
}));
|
||||
|
||||
// The sync logic has its own suite.
|
||||
vi.mock("@/v2/utils/saveSync", () => import("@/test-utils/deviceSaveSync"));
|
||||
|
||||
// The runtime is a document-level singleton with its own suite; here it only
|
||||
// has to say which base the emulator payloads follow.
|
||||
vi.mock("./jsDosRuntime", () => ({
|
||||
@@ -135,6 +139,10 @@ beforeEach(() => {
|
||||
mocks.userId = 7;
|
||||
mocks.confirm.mockResolvedValue(false);
|
||||
mocks.getRom.mockResolvedValue({ data: rom });
|
||||
saveSyncMocks.prepare.mockReset().mockResolvedValue([]);
|
||||
saveSyncMocks.capture.mockReset().mockResolvedValue(undefined);
|
||||
saveSyncMocks.push.mockReset().mockResolvedValue(true);
|
||||
saveSyncMocks.pushOnUnload.mockReset();
|
||||
delete window.Dos;
|
||||
});
|
||||
|
||||
@@ -501,3 +509,100 @@ describe("JsDos player exit", () => {
|
||||
secondWrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("JsDos save sync", () => {
|
||||
function optionsOf(): Partial<JsDosOptions> {
|
||||
return vi.mocked(window.Dos!).mock.calls[0]![1];
|
||||
}
|
||||
|
||||
it("boots from the synced changes instead of browser storage", async () => {
|
||||
const changes = new Uint8Array([80, 75]);
|
||||
saveSyncMocks.prepare.mockResolvedValue([
|
||||
{ slot: "autosave", bytes: changes },
|
||||
]);
|
||||
const wrapper = await mountPlayer(makeHandle());
|
||||
|
||||
expect(saveSyncMocks.args).toEqual([rom, 7, "jsdos"]);
|
||||
expect(saveSyncMocks.prepare).toHaveBeenCalledWith([]);
|
||||
const { fsChanges } = optionsOf();
|
||||
expect(fsChanges?.local).toBe(false);
|
||||
await expect(fsChanges?.pull?.("key")).resolves.toBe(changes);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("starts fresh when nothing was saved", async () => {
|
||||
const wrapper = await mountPlayer(makeHandle());
|
||||
|
||||
await expect(optionsOf().fsChanges?.pull?.("key")).resolves.toBeNull();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("uploads each save js-dos makes, and serves it on the next pull", async () => {
|
||||
const wrapper = await mountPlayer(makeHandle());
|
||||
const bytes = new Uint8Array([1, 2]);
|
||||
|
||||
await optionsOf().fsChanges?.push?.("key", bytes);
|
||||
|
||||
expect(saveSyncMocks.capture).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
slot: "autosave",
|
||||
fileName: "Windows Game.changes",
|
||||
bytes,
|
||||
}),
|
||||
]);
|
||||
expect(saveSyncMocks.push).toHaveBeenCalled();
|
||||
await expect(optionsOf().fsChanges?.pull?.("key")).resolves.toBe(bytes);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("fails the save when the upload fails, so leaving asks first", async () => {
|
||||
saveSyncMocks.push.mockResolvedValue(false);
|
||||
const wrapper = await mountPlayer(makeHandle());
|
||||
|
||||
await expect(
|
||||
optionsOf().fsChanges?.push?.("key", new Uint8Array([1])),
|
||||
).rejects.toThrow();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("carries saves js-dos kept in the browser into the synced copy", async () => {
|
||||
const removeEntry = vi.fn().mockResolvedValue(undefined);
|
||||
const file = new File([new Uint8Array([9])], "changes", {
|
||||
lastModified: 1234,
|
||||
});
|
||||
const saves = {
|
||||
getFileHandle: vi.fn().mockResolvedValue({ getFile: async () => file }),
|
||||
removeEntry,
|
||||
};
|
||||
vi.stubGlobal("navigator", {
|
||||
...navigator,
|
||||
storage: {
|
||||
getDirectory: async () => ({
|
||||
getDirectoryHandle: async () => ({
|
||||
getDirectoryHandle: async () => saves,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
});
|
||||
const wrapper = await mountPlayer(makeHandle());
|
||||
|
||||
expect(saves.getFileHandle).toHaveBeenCalledWith(
|
||||
"romm-user-7-rom-1.changes",
|
||||
);
|
||||
const [legacy] = saveSyncMocks.prepare.mock.calls[0]![0];
|
||||
expect(legacy).toMatchObject({ slot: "autosave", updatedAt: 1234 });
|
||||
expect([...legacy.bytes]).toEqual([9]);
|
||||
expect(removeEntry).toHaveBeenCalledWith("romm-user-7-rom-1.changes");
|
||||
wrapper.unmount();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("sends pending saves when the page goes away", async () => {
|
||||
const wrapper = await mountPlayer(makeHandle());
|
||||
|
||||
window.dispatchEvent(new Event("pagehide"));
|
||||
|
||||
expect(saveSyncMocks.pushOnUnload).toHaveBeenCalled();
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,14 +5,17 @@ import { nextTick, onBeforeUnmount, onMounted, ref, shallowRef } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { onBeforeRouteLeave } from "vue-router";
|
||||
import romApi from "@/services/api/rom";
|
||||
import { AUTOSAVE_SLOT } from "@/services/api/save";
|
||||
import storeAuth from "@/stores/auth";
|
||||
import type { DetailedRom } from "@/stores/roms";
|
||||
import type { JsDosProps } from "@/types/js-dos";
|
||||
import { getDownloadPath } from "@/utils";
|
||||
import PlayerShell from "@/v2/components/Player/PlayerShell.vue";
|
||||
import { useConfirm } from "@/v2/composables/useConfirm";
|
||||
import { useDeviceSaveSync } from "@/v2/composables/useDeviceSaveSync";
|
||||
import { useFullscreenFallback } from "@/v2/composables/useFullscreenFallback";
|
||||
import { useFullscreenPref } from "@/v2/composables/useFullscreenPref";
|
||||
import { useIsAlive } from "@/v2/composables/useIsAlive";
|
||||
import {
|
||||
hasSharedArrayBuffer,
|
||||
isRelaunchMarker,
|
||||
@@ -24,6 +27,7 @@ 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 type { PlayerSaveFile } from "@/v2/utils/saveSync";
|
||||
import { loadJsDosRuntime } from "./jsDosRuntime";
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -33,6 +37,7 @@ useFullscreenFallback();
|
||||
const playSession = usePlaySession();
|
||||
const snackbar = useSnackbar();
|
||||
const confirm = useConfirm();
|
||||
const alive = useIsAlive();
|
||||
// An isolated document cannot host the rest of the app, so a player that ran
|
||||
// hands the tab back a fresh one.
|
||||
let runtimeBound = false;
|
||||
@@ -42,9 +47,11 @@ const rom = shallowRef<DetailedRom | null>(null);
|
||||
const gameRunning = ref(false);
|
||||
usePlayingWhile(gameRunning);
|
||||
const quitting = ref(false);
|
||||
const preparing = ref(false);
|
||||
const stage = ref<HTMLDivElement | null>(null);
|
||||
|
||||
let dos: JsDosProps | null = null;
|
||||
const saveSync = useDeviceSaveSync({ emulator: "jsdos" });
|
||||
|
||||
const { romId, heroRom, title, platformLabel } = usePlayerHero(rom);
|
||||
|
||||
@@ -55,6 +62,47 @@ const {
|
||||
relaunch: relaunchIsolated,
|
||||
} = useIsolatedLaunch<true>("jsdos", romId, isRelaunchMarker);
|
||||
|
||||
// The browser storage js-dos kept saves in before they synced; js-dos itself
|
||||
// would share them between accounts, hence the user in the key.
|
||||
function legacyChangesKey(userId: number, id: number): string {
|
||||
return `romm-user-${userId}-rom-${id}.changes`;
|
||||
}
|
||||
|
||||
async function legacyChangesDir(): Promise<FileSystemDirectoryHandle> {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
const jsdos = await root.getDirectoryHandle("jsdos");
|
||||
return jsdos.getDirectoryHandle("saves");
|
||||
}
|
||||
|
||||
async function readLegacyChanges(
|
||||
key: string,
|
||||
fileName: string,
|
||||
): Promise<PlayerSaveFile[]> {
|
||||
try {
|
||||
const handle = await (await legacyChangesDir()).getFileHandle(key);
|
||||
const file = await handle.getFile();
|
||||
return [
|
||||
{
|
||||
slot: AUTOSAVE_SLOT,
|
||||
fileName,
|
||||
bytes: new Uint8Array(await file.arrayBuffer()),
|
||||
updatedAt: file.lastModified,
|
||||
},
|
||||
];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Once carried into the synced copy it would only overwrite newer progress.
|
||||
async function forgetLegacyChanges(key: string) {
|
||||
try {
|
||||
await (await legacyChangesDir()).removeEntry(key);
|
||||
} catch {
|
||||
// Nothing was kept there.
|
||||
}
|
||||
}
|
||||
|
||||
async function onPlay() {
|
||||
const currentRom = rom.value;
|
||||
const userId = authStore.user?.id;
|
||||
@@ -80,6 +128,26 @@ async function onPlay() {
|
||||
snackbar.error(t("play.stream-error-generic"));
|
||||
return;
|
||||
}
|
||||
|
||||
const key = legacyChangesKey(userId, currentRom.id);
|
||||
const fileName = `${currentRom.fs_name_no_ext}.changes`;
|
||||
let changes: Uint8Array | null;
|
||||
preparing.value = true;
|
||||
try {
|
||||
const legacy = await readLegacyChanges(key, fileName);
|
||||
const saves = (await saveSync.prepare(currentRom, legacy)) ?? [];
|
||||
if (legacy.length) await forgetLegacyChanges(key);
|
||||
changes = saves.find((save) => save.slot === AUTOSAVE_SLOT)?.bytes ?? null;
|
||||
} catch (error) {
|
||||
console.error("[js-dos] Save storage failed", error);
|
||||
snackbar.error(t("play.stream-error-generic"));
|
||||
return;
|
||||
} finally {
|
||||
preparing.value = false;
|
||||
}
|
||||
if (!alive.value) return;
|
||||
|
||||
saveSync.start();
|
||||
gameRunning.value = true;
|
||||
|
||||
await nextTick();
|
||||
@@ -100,11 +168,18 @@ async function onPlay() {
|
||||
// js-dos calls exitFullscreen() unguarded when this is false, which
|
||||
// rejects if the document was never fullscreen. Only ever opt in.
|
||||
...(fullscreenOnPlay.value ? { fullScreen: true } : {}),
|
||||
// The changes live in RomM's synced copy, never in js-dos's own storage.
|
||||
fsChanges: {
|
||||
local: true,
|
||||
// js-dos defaults to the bundle URL, which would share saves between
|
||||
// RomM accounts using the same browser profile.
|
||||
urlToKey: async () => `romm-user-${userId}-rom-${currentRom.id}.changes`,
|
||||
local: false,
|
||||
urlToKey: async () => key,
|
||||
pull: async () => changes,
|
||||
push: async (_key, bytes) => {
|
||||
changes = bytes;
|
||||
await saveSync.capture([
|
||||
{ slot: AUTOSAVE_SLOT, fileName, bytes, updatedAt: Date.now() },
|
||||
]);
|
||||
if (!(await saveSync.push())) throw new Error("Save upload failed");
|
||||
},
|
||||
},
|
||||
});
|
||||
// Hide the dos.zone cloud integration.
|
||||
@@ -134,6 +209,7 @@ async function saveQuietly(handle: JsDosProps) {
|
||||
function teardown() {
|
||||
playSession.flush();
|
||||
stopDos();
|
||||
saveSync.stop();
|
||||
}
|
||||
|
||||
async function leavePlayer(destination: string) {
|
||||
@@ -196,7 +272,7 @@ onBeforeUnmount(teardown);
|
||||
:title="title"
|
||||
:platform-label="platformLabel"
|
||||
:rom-id="romId"
|
||||
:ready="!!rom && !relaunching"
|
||||
:ready="!!rom && !relaunching && !preparing"
|
||||
:running="gameRunning"
|
||||
:quitting="quitting"
|
||||
@play="onPlay"
|
||||
@@ -204,10 +280,6 @@ onBeforeUnmount(teardown);
|
||||
>
|
||||
<template #settings>
|
||||
<RSwitch v-model="fullscreenOnPlay" :label="t('play.full-screen')" />
|
||||
|
||||
<p class="r-v2-jsdos__save-note">
|
||||
{{ t("play.jsdos-browser-save-warning") }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template #stage>
|
||||
@@ -217,12 +289,6 @@ onBeforeUnmount(teardown);
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.r-v2-jsdos__save-note {
|
||||
margin: 0;
|
||||
color: var(--r-color-fg-muted);
|
||||
font-size: var(--r-font-size-sm);
|
||||
}
|
||||
|
||||
.r-v2-jsdos__stage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { flushPromises, mount } from "@vue/test-utils";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { saveSyncMocks } from "@/test-utils/deviceSaveSync";
|
||||
import Pico8 from "./Pico8.vue";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getRom: vi.fn(),
|
||||
confirm: vi.fn(),
|
||||
exitLeave: vi.fn(),
|
||||
routeLeaveGuard: null as ((to: { fullPath: string }) => unknown) | null,
|
||||
runtime: {
|
||||
frameRate: 30,
|
||||
audioSampleRate: 22050,
|
||||
samplesPerFrame: 735,
|
||||
loadCart: vi.fn(),
|
||||
writeCartData: vi.fn(),
|
||||
flushCartData: vi.fn(),
|
||||
advance: vi.fn(),
|
||||
render: vi.fn(),
|
||||
readAudio: vi.fn(),
|
||||
@@ -17,6 +23,27 @@ const mocks = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n");
|
||||
vi.mock("vue-router", () => ({
|
||||
onBeforeRouteLeave: (guard: (to: { fullPath: string }) => unknown) => {
|
||||
mocks.routeLeaveGuard = guard;
|
||||
},
|
||||
useRoute: () => ({ params: { rom: "1" } }),
|
||||
useRouter: () => ({ replace: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@/plugins/router", () => ({
|
||||
ROUTES: { ROM: "rom", PLATFORM: "platform" },
|
||||
}));
|
||||
vi.mock("@/stores/auth", () => ({ default: () => ({ user: { id: 7 } }) }));
|
||||
vi.mock("@/v2/composables/useConfirm", () => ({
|
||||
useConfirm: () => mocks.confirm,
|
||||
}));
|
||||
vi.mock("@/v2/composables/usePlayerExit", () => ({
|
||||
usePlayerExit: () => ({
|
||||
leave: mocks.exitLeave,
|
||||
guard: () => Promise.resolve(true),
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/v2/utils/saveSync", () => import("@/test-utils/deviceSaveSync"));
|
||||
vi.mock("@/services/api/rom", () => ({ default: { getRom: mocks.getRom } }));
|
||||
vi.mock("@/utils", () => ({ getDownloadPath: () => "/cart.p8.png" }));
|
||||
vi.mock("@/v2/utils/pico8Runtime", () => ({
|
||||
@@ -32,6 +59,7 @@ vi.mock("@/v2/utils/pico8Runtime", () => ({
|
||||
x: 32,
|
||||
pause: 64,
|
||||
},
|
||||
cartDataFileName: (key: string) => `${key}.p8d.txt`,
|
||||
createPico8Runtime: async () => mocks.runtime,
|
||||
}));
|
||||
vi.mock("@/v2/utils/pico8Audio", () => ({
|
||||
@@ -107,6 +135,10 @@ describe("Pico8 frame loop", () => {
|
||||
);
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
mocks.getRom.mockResolvedValue({ data: { id: 1, name: "Celeste" } });
|
||||
saveSyncMocks.prepare.mockResolvedValue([]);
|
||||
saveSyncMocks.capture.mockResolvedValue(undefined);
|
||||
saveSyncMocks.push.mockResolvedValue(true);
|
||||
mocks.runtime.flushCartData.mockReturnValue([]);
|
||||
});
|
||||
|
||||
async function play() {
|
||||
@@ -166,3 +198,135 @@ describe("Pico8 frame loop", () => {
|
||||
expect(frames.size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Pico8 cart data", () => {
|
||||
const PlayerShellWithQuit = {
|
||||
emits: ["play"],
|
||||
template: `<div><button class="play" @click="$emit('play')" /><slot name="stage-actions" /><slot name="stage" /></div>`,
|
||||
};
|
||||
const back = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("requestAnimationFrame", () => 1);
|
||||
vi.stubGlobal("cancelAnimationFrame", () => undefined);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response(new Uint8Array([1, 2, 3]))),
|
||||
);
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
vi.spyOn(window.history, "back").mockImplementation(back);
|
||||
mocks.getRom.mockResolvedValue({ data: { id: 1, name: "Celeste" } });
|
||||
saveSyncMocks.prepare.mockResolvedValue([
|
||||
{ slot: "celeste", bytes: new Uint8Array([9]) },
|
||||
]);
|
||||
saveSyncMocks.capture.mockResolvedValue(undefined);
|
||||
saveSyncMocks.push.mockResolvedValue(true);
|
||||
mocks.runtime.flushCartData.mockReturnValue([
|
||||
{ key: "celeste", bytes: new Uint8Array([10]) },
|
||||
]);
|
||||
mocks.confirm.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
async function play() {
|
||||
const wrapper = mount(Pico8, {
|
||||
global: {
|
||||
stubs: {
|
||||
PlayerShell: PlayerShellWithQuit,
|
||||
RBtn: {
|
||||
props: { ariaLabel: { type: String, default: "" } },
|
||||
emits: ["click"],
|
||||
template: `<button :data-label="ariaLabel" @click="$emit('click')" />`,
|
||||
},
|
||||
RSpinner: true,
|
||||
RSwitch: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
await wrapper.get(".play").trigger("click");
|
||||
await flushPromises();
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
async function quit(wrapper: Awaited<ReturnType<typeof play>>) {
|
||||
await wrapper.get('[data-label="play.quit"]').trigger("click");
|
||||
await flushPromises();
|
||||
}
|
||||
|
||||
it("restores the saved cart data before the cart loads", async () => {
|
||||
const wrapper = await play();
|
||||
|
||||
expect(saveSyncMocks.args).toEqual([
|
||||
{ id: 1, name: "Celeste" },
|
||||
7,
|
||||
"pico8",
|
||||
]);
|
||||
expect(mocks.runtime.writeCartData).toHaveBeenCalledWith([
|
||||
{ key: "celeste", bytes: new Uint8Array([9]) },
|
||||
]);
|
||||
expect(
|
||||
mocks.runtime.writeCartData.mock.invocationCallOrder[0],
|
||||
).toBeLessThan(mocks.runtime.loadCart.mock.invocationCallOrder[0]!);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("flushes and uploads the cart data before quitting", async () => {
|
||||
const wrapper = await play();
|
||||
|
||||
await quit(wrapper);
|
||||
|
||||
expect(saveSyncMocks.capture).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
slot: "celeste",
|
||||
fileName: "celeste.p8d.txt",
|
||||
bytes: new Uint8Array([10]),
|
||||
}),
|
||||
]);
|
||||
expect(saveSyncMocks.push).toHaveBeenCalled();
|
||||
expect(mocks.confirm).not.toHaveBeenCalled();
|
||||
expect(back).toHaveBeenCalled();
|
||||
expect(mocks.runtime.dispose).toHaveBeenCalled();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("flushes and sends the cart data as the page goes away", async () => {
|
||||
const wrapper = await play();
|
||||
|
||||
window.dispatchEvent(new Event("pagehide"));
|
||||
|
||||
expect(mocks.runtime.flushCartData).toHaveBeenCalled();
|
||||
expect(saveSyncMocks.captureOnUnload).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ slot: "celeste" }),
|
||||
]);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("restarts the cart when the player stays after a failed upload", async () => {
|
||||
saveSyncMocks.push.mockResolvedValue(false);
|
||||
const wrapper = await play();
|
||||
mocks.runtime.loadCart.mockClear();
|
||||
|
||||
await quit(wrapper);
|
||||
|
||||
expect(mocks.confirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: "play.quit-before-save-synced" }),
|
||||
);
|
||||
expect(back).not.toHaveBeenCalled();
|
||||
expect(mocks.runtime.loadCart).toHaveBeenCalledWith(
|
||||
new Uint8Array([1, 2, 3]),
|
||||
);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("saves before a route change and then follows it", async () => {
|
||||
const wrapper = await play();
|
||||
|
||||
expect(mocks.routeLeaveGuard?.({ fullPath: "/rom/1" })).toBe(false);
|
||||
await flushPromises();
|
||||
|
||||
expect(saveSyncMocks.push).toHaveBeenCalled();
|
||||
expect(mocks.exitLeave).toHaveBeenCalledWith("/rom/1");
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,13 +11,16 @@ import {
|
||||
watch,
|
||||
} from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { onBeforeRouteLeave } from "vue-router";
|
||||
import romApi from "@/services/api/rom";
|
||||
import type { DetailedRom } from "@/stores/roms";
|
||||
import { getDownloadPath } from "@/utils";
|
||||
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";
|
||||
@@ -27,15 +30,19 @@ import { createPico8Audio, type Pico8Audio } from "@/v2/utils/pico8Audio";
|
||||
import { createPico8Input } from "@/v2/utils/pico8Input";
|
||||
import { createPico8Pacer, type Pico8Pacer } from "@/v2/utils/pico8Pacer";
|
||||
import {
|
||||
cartDataFileName,
|
||||
createPico8Runtime,
|
||||
PICO8_FRAME_RATE,
|
||||
PICO8_HEIGHT,
|
||||
PICO8_INPUT_BITS,
|
||||
PICO8_WIDTH,
|
||||
type Pico8CartData,
|
||||
type Pico8Runtime,
|
||||
} from "@/v2/utils/pico8Runtime";
|
||||
import type { PlayerSaveFile } from "@/v2/utils/saveSync";
|
||||
|
||||
const { t } = useI18n();
|
||||
const exit = usePlayerExit();
|
||||
const playSession = usePlaySession();
|
||||
const snackbar = useSnackbar();
|
||||
const { fullscreenOnPlay } = useFullscreenPref();
|
||||
@@ -45,6 +52,7 @@ const rom = shallowRef<DetailedRom | null>(null);
|
||||
const gameRunning = ref(false);
|
||||
usePlayingWhile(gameRunning);
|
||||
const loading = ref(false);
|
||||
const quitting = ref(false);
|
||||
const stage = ref<HTMLDivElement | null>(null);
|
||||
const canvas = ref<HTMLCanvasElement | null>(null);
|
||||
|
||||
@@ -58,6 +66,7 @@ const {
|
||||
let runtime: Pico8Runtime | null = null;
|
||||
let audio: Pico8Audio | null = null;
|
||||
let pacer: Pico8Pacer | null = null;
|
||||
let cartBytes: Uint8Array | null = null;
|
||||
|
||||
const input = createPico8Input();
|
||||
const { touchMask } = input;
|
||||
@@ -192,6 +201,8 @@ function releaseGame() {
|
||||
looping.value = false;
|
||||
runtime?.dispose();
|
||||
runtime = null;
|
||||
saveSync.stop();
|
||||
cartBytes = null;
|
||||
pacer = null;
|
||||
audio?.close();
|
||||
audio = null;
|
||||
@@ -215,6 +226,77 @@ async function fetchCartBytes(target: DetailedRom) {
|
||||
return new Uint8Array(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
// Cart data syncs like any save; a browser that cannot reach the server keeps
|
||||
// playing with what it last held.
|
||||
async function prepareCartData(target: DetailedRom): Promise<Pico8CartData[]> {
|
||||
try {
|
||||
const saves = (await saveSync.prepare(target)) ?? [];
|
||||
saveSync.start();
|
||||
return saves.map((save) => ({ key: save.slot, bytes: save.bytes }));
|
||||
} catch (error) {
|
||||
console.error("[PICO-8] Saves are unavailable", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function cartDataSaves(files: Pico8CartData[]): PlayerSaveFile[] {
|
||||
const now = Date.now();
|
||||
return files.map((file) => ({
|
||||
slot: file.key,
|
||||
fileName: cartDataFileName(file.key),
|
||||
bytes: file.bytes,
|
||||
updatedAt: now,
|
||||
}));
|
||||
}
|
||||
|
||||
// FAKE-08 writes cart data only when a cart unloads, so it is captured on the
|
||||
// way out.
|
||||
const saveSync = useDeviceSaveSync({
|
||||
emulator: "pico8",
|
||||
readOnUnload: () => (runtime ? cartDataSaves(runtime.flushCartData()) : []),
|
||||
});
|
||||
|
||||
async function saveCartData(): Promise<boolean> {
|
||||
const active = runtime;
|
||||
if (!saveSync.isActive() || !active) return true;
|
||||
looping.value = false;
|
||||
try {
|
||||
await saveSync.capture(cartDataSaves(active.flushCartData()));
|
||||
} catch (error) {
|
||||
console.error("[PICO-8] Saving cart data failed", error);
|
||||
return false;
|
||||
}
|
||||
return saveSync.push();
|
||||
}
|
||||
|
||||
// Flushing swapped in a blank cart, so staying restarts the game's own.
|
||||
function resumeCart() {
|
||||
if (!runtime || !cartBytes) return;
|
||||
try {
|
||||
runtime.loadCart(cartBytes);
|
||||
startLoop();
|
||||
} catch (error) {
|
||||
showPlayError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function leavePlayer(leave: () => void) {
|
||||
if (quitting.value) return;
|
||||
quitting.value = true;
|
||||
|
||||
if (!(await saveCartData())) {
|
||||
if (!(await saveSync.confirmDiscard())) {
|
||||
resumeCart();
|
||||
quitting.value = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
releaseGame();
|
||||
quitting.value = false;
|
||||
leave();
|
||||
}
|
||||
|
||||
async function onPlay() {
|
||||
const currentRom = rom.value;
|
||||
if (!currentRom || gameRunning.value) return;
|
||||
@@ -232,9 +314,10 @@ async function onPlay() {
|
||||
|
||||
let nextRuntime: Pico8Runtime | null = null;
|
||||
try {
|
||||
const [runtimeResult, bytes] = await Promise.all([
|
||||
const [runtimeResult, bytes, cartData] = await Promise.all([
|
||||
createPico8Runtime(canvasElement),
|
||||
fetchCartBytes(currentRom),
|
||||
prepareCartData(currentRom),
|
||||
]);
|
||||
nextRuntime = runtimeResult;
|
||||
if (!gameRunning.value) {
|
||||
@@ -242,7 +325,9 @@ async function onPlay() {
|
||||
nextRuntime = null;
|
||||
return;
|
||||
}
|
||||
nextRuntime.writeCartData(cartData);
|
||||
nextRuntime.loadCart(bytes);
|
||||
cartBytes = bytes;
|
||||
runtime = nextRuntime;
|
||||
nextRuntime = null;
|
||||
|
||||
@@ -273,10 +358,15 @@ async function onPlay() {
|
||||
}
|
||||
|
||||
function onlyQuit() {
|
||||
releaseGame();
|
||||
window.history.back();
|
||||
void leavePlayer(() => window.history.back());
|
||||
}
|
||||
|
||||
onBeforeRouteLeave((to) => {
|
||||
if (!runtime) return exit.guard(to);
|
||||
void leavePlayer(() => exit.leave(to.fullPath));
|
||||
return false;
|
||||
});
|
||||
|
||||
useEventListener(window, "keydown", onKeyDown);
|
||||
useEventListener(window, "keyup", onKeyUp);
|
||||
useEventListener(window, "blur", input.reset);
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { flushPromises, mount, type VueWrapper } from "@vue/test-utils";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { nextTick } from "vue";
|
||||
import { saveSyncMocks } from "@/test-utils/deviceSaveSync";
|
||||
import type { RuffleSourceAPI } from "@/types/ruffle";
|
||||
import { zipRuffleSaves } from "@/v2/utils/ruffleSaves";
|
||||
import Ruffle from "./Ruffle.vue";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
confirm: vi.fn(),
|
||||
exitLeave: vi.fn(),
|
||||
routeLeaveGuard: null as ((to: { fullPath: string }) => unknown) | null,
|
||||
getRom: vi.fn(),
|
||||
playSessionStart: vi.fn(),
|
||||
flushPlaySession: vi.fn(),
|
||||
@@ -16,10 +21,28 @@ const mocks = vi.hoisted(() => ({
|
||||
vi.mock("vue-i18n");
|
||||
|
||||
vi.mock("vue-router", () => ({
|
||||
onBeforeRouteLeave: (guard: (to: { fullPath: string }) => unknown) => {
|
||||
mocks.routeLeaveGuard = guard;
|
||||
},
|
||||
useRoute: () => ({ params: { rom: "1" } }),
|
||||
useRouter: () => ({ push: mocks.push }),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/auth", () => ({ default: () => ({ user: { id: 7 } }) }));
|
||||
|
||||
vi.mock("@/v2/composables/useConfirm", () => ({
|
||||
useConfirm: () => mocks.confirm,
|
||||
}));
|
||||
|
||||
vi.mock("@/v2/composables/usePlayerExit", () => ({
|
||||
usePlayerExit: () => ({
|
||||
leave: mocks.exitLeave,
|
||||
guard: () => Promise.resolve(true),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/v2/utils/saveSync", () => import("@/test-utils/deviceSaveSync"));
|
||||
|
||||
vi.mock("@/plugins/router", () => ({
|
||||
ROUTES: { ROM: "rom", PLATFORM: "platform" },
|
||||
}));
|
||||
@@ -90,6 +113,12 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
saveSyncMocks.prepare.mockResolvedValue([]);
|
||||
saveSyncMocks.capture.mockResolvedValue(undefined);
|
||||
saveSyncMocks.push.mockResolvedValue(true);
|
||||
mocks.confirm.mockResolvedValue(false);
|
||||
mocks.getRom.mockResolvedValue({ data: rom });
|
||||
window.RufflePlayer = {
|
||||
newest: () => null,
|
||||
@@ -125,6 +154,7 @@ async function mountAndPlay(): Promise<VueWrapper> {
|
||||
});
|
||||
await flushPromises();
|
||||
await wrapper.get(".r-v2-player__play").trigger("click");
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
return wrapper;
|
||||
}
|
||||
@@ -159,3 +189,125 @@ describe("Ruffle launch", () => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
const HOST = window.location.hostname;
|
||||
const SWF_KEY = "api/roms/1/content/game.swf/progress";
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
describe("Ruffle saves", () => {
|
||||
beforeEach(() => {
|
||||
window.RufflePlayer = {
|
||||
newest: () => makeRuffleSource(),
|
||||
} as unknown as typeof window.RufflePlayer;
|
||||
});
|
||||
|
||||
it("syncs saves left in storage and restores the synced copy", async () => {
|
||||
localStorage.setItem(`${HOST}/${SWF_KEY}`, stored(sol(1)));
|
||||
saveSyncMocks.prepare.mockResolvedValue([
|
||||
{ slot: "autosave", bytes: zipRuffleSaves({ [SWF_KEY]: sol(2) }) },
|
||||
]);
|
||||
|
||||
const wrapper = await mountAndPlay();
|
||||
|
||||
const [leftover] = saveSyncMocks.prepare.mock.calls[0]![0];
|
||||
expect(leftover).toMatchObject({
|
||||
slot: "autosave",
|
||||
fileName: "Flash Game.sol.zip",
|
||||
});
|
||||
expect(localStorage.getItem(`${HOST}/${SWF_KEY}`)).toBe(stored(sol(2)));
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("uploads the saves and clears them from storage on quit", async () => {
|
||||
const wrapper = await mountAndPlay();
|
||||
localStorage.setItem(`${HOST}/${SWF_KEY}`, stored(sol(3)));
|
||||
|
||||
expect(mocks.routeLeaveGuard?.({ fullPath: "/rom/1" })).toBe(false);
|
||||
await flushPromises();
|
||||
|
||||
expect(saveSyncMocks.capture).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ slot: "autosave" }),
|
||||
]);
|
||||
expect(saveSyncMocks.push).toHaveBeenCalled();
|
||||
expect(localStorage.getItem(`${HOST}/${SWF_KEY}`)).toBeNull();
|
||||
expect(mocks.exitLeave).toHaveBeenCalledWith("/rom/1");
|
||||
expect(document.querySelector("#r-v2-ruffle-stage > *")).toBeNull();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("sends the saves and keeps them in storage as the page goes away", async () => {
|
||||
const wrapper = await mountAndPlay();
|
||||
localStorage.setItem(`${HOST}/${SWF_KEY}`, stored(sol(4)));
|
||||
|
||||
window.dispatchEvent(new Event("pagehide"));
|
||||
|
||||
expect(saveSyncMocks.captureOnUnload).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ slot: "autosave" }),
|
||||
]);
|
||||
expect(localStorage.getItem(`${HOST}/${SWF_KEY}`)).toBe(stored(sol(4)));
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("uploads a change once two polls agree on it", async () => {
|
||||
// Only the poll is faked; mounting still flushes on real timers.
|
||||
vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
|
||||
const wrapper = await mountAndPlay();
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(saveSyncMocks.capture).toHaveBeenLastCalledWith([]);
|
||||
|
||||
localStorage.setItem(`${HOST}/${SWF_KEY}`, stored(sol(5)));
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(saveSyncMocks.capture).toHaveBeenLastCalledWith([]);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(saveSyncMocks.capture).toHaveBeenLastCalledWith([
|
||||
expect.objectContaining({ slot: "autosave" }),
|
||||
]);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(saveSyncMocks.capture).toHaveBeenLastCalledWith([]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
wrapper.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("restarts the game when the player stays after a failed upload", async () => {
|
||||
saveSyncMocks.push.mockResolvedValue(false);
|
||||
const wrapper = await mountAndPlay();
|
||||
localStorage.setItem(`${HOST}/${SWF_KEY}`, stored(sol(3)));
|
||||
|
||||
mocks.routeLeaveGuard?.({ fullPath: "/rom/1" });
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.confirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: "play.quit-before-save-synced" }),
|
||||
);
|
||||
expect(mocks.exitLeave).not.toHaveBeenCalled();
|
||||
expect(localStorage.getItem(`${HOST}/${SWF_KEY}`)).toBe(stored(sol(3)));
|
||||
expect(document.getElementById("r-v2-ruffle-stage")?.children).toHaveLength(
|
||||
1,
|
||||
);
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,30 +6,54 @@
|
||||
import { RIcon, RSwitch } from "@v2/lib";
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, shallowRef } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { onBeforeRouteLeave } from "vue-router";
|
||||
import { userStorage } from "@/composables/useUserLocalStorage";
|
||||
import romApi from "@/services/api/rom";
|
||||
import { AUTOSAVE_SLOT } from "@/services/api/save";
|
||||
import type { DetailedRom } from "@/stores/roms";
|
||||
import type { RuffleSourceAPI } from "@/types/ruffle";
|
||||
import { getDownloadPath } from "@/utils";
|
||||
import PlayerShell from "@/v2/components/Player/PlayerShell.vue";
|
||||
import { useDeviceSaveSync } from "@/v2/composables/useDeviceSaveSync";
|
||||
import { useFullscreenFallback } from "@/v2/composables/useFullscreenFallback";
|
||||
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 { usePlayerHero } from "@/v2/composables/usePlayerHero";
|
||||
import { usePlayingWhile } from "@/v2/composables/useStageActive";
|
||||
import { useUnloadGuard } from "@/v2/composables/useUnloadGuard";
|
||||
import { colorCanvas } from "@/v2/tokens";
|
||||
import {
|
||||
readRuffleSaves,
|
||||
decodeRuffleStorage,
|
||||
readRuffleStorage,
|
||||
removeRuffleSaves,
|
||||
sameRuffleStorage,
|
||||
swfStoragePath,
|
||||
unzipRuffleSaves,
|
||||
writeRuffleSaves,
|
||||
zipRuffleSaves,
|
||||
type RuffleSaves,
|
||||
type RuffleStorage,
|
||||
} from "@/v2/utils/ruffleSaves";
|
||||
import type { PlayerSaveFile } from "@/v2/utils/saveSync";
|
||||
|
||||
const RUFFLE_VERSION = "0.2.0-nightly.2025.8.14";
|
||||
const DEFAULT_BACKGROUND_COLOR = colorCanvas.bgDeep;
|
||||
|
||||
type RufflePlayer = ReturnType<RuffleSourceAPI["createPlayer"]>;
|
||||
|
||||
const { t } = useI18n();
|
||||
const { fullscreenOnPlay } = useFullscreenPref();
|
||||
useFullscreenFallback();
|
||||
const playSession = usePlaySession();
|
||||
const exit = usePlayerExit();
|
||||
const alive = useIsAlive();
|
||||
|
||||
const rom = shallowRef<DetailedRom | null>(null);
|
||||
const gameRunning = ref(false);
|
||||
const quitting = ref(false);
|
||||
// Flash games are keyboard-driven, so hotkeys and pad navigation stay muted.
|
||||
usePlayingWhile(gameRunning);
|
||||
const backgroundColor = ref<string>(DEFAULT_BACKGROUND_COLOR);
|
||||
@@ -55,56 +79,164 @@ window.RufflePlayer = window.RufflePlayer || {};
|
||||
|
||||
const { romId, heroRom, title, platformLabel } = usePlayerHero(rom);
|
||||
|
||||
let player: RufflePlayer | null = null;
|
||||
let swfUrl = "";
|
||||
const host = window.location.hostname;
|
||||
|
||||
// Nothing is running, so drop the guard and the input mute the launch armed.
|
||||
function abortPlay() {
|
||||
gameRunning.value = false;
|
||||
}
|
||||
|
||||
function onPlay() {
|
||||
gameRunning.value = true;
|
||||
function storedSaves(): RuffleSaves {
|
||||
return readRuffleSaves(host, swfStoragePath(swfUrl));
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
if (!rom.value) {
|
||||
abortPlay();
|
||||
return;
|
||||
// Every SharedObject a game wrote travels as one zip in the autosave slot.
|
||||
function saveFilesOf(
|
||||
target: DetailedRom,
|
||||
saves = storedSaves(),
|
||||
): PlayerSaveFile[] {
|
||||
if (Object.keys(saves).length === 0) return [];
|
||||
return [
|
||||
{
|
||||
slot: AUTOSAVE_SLOT,
|
||||
fileName: `${target.fs_name_no_ext}.sol.zip`,
|
||||
bytes: zipRuffleSaves(saves),
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Storage as the last poll read it, and as last handed to sync; comparing the
|
||||
// raw strings keeps an idle poll from decoding, zipping or hashing anything.
|
||||
let lastRead: RuffleStorage | null = null;
|
||||
let lastCaptured: RuffleStorage | null = null;
|
||||
|
||||
// As EmulatorJS does, a change is taken once two polls agree on it, so a game
|
||||
// mid-flush is not uploaded; leaving cannot wait for a second poll.
|
||||
function changedSaveFiles(leaving: boolean): PlayerSaveFile[] {
|
||||
const target = rom.value;
|
||||
if (!target) return [];
|
||||
const stored = readRuffleStorage(host, swfStoragePath(swfUrl));
|
||||
const settled = lastRead !== null && sameRuffleStorage(lastRead, stored);
|
||||
lastRead = stored;
|
||||
if (!settled && !leaving) return [];
|
||||
if (lastCaptured && sameRuffleStorage(lastCaptured, stored)) return [];
|
||||
lastCaptured = stored;
|
||||
return saveFilesOf(target, decodeRuffleStorage(stored));
|
||||
}
|
||||
|
||||
// A game writes a SharedObject whenever it flushes one, so storage is polled
|
||||
// every second, as EmulatorJS polls its SRAM.
|
||||
const saveSync = useDeviceSaveSync({
|
||||
emulator: "ruffle",
|
||||
read: async (leaving) => changedSaveFiles(leaving),
|
||||
readOnUnload: () => changedSaveFiles(true),
|
||||
pollMs: 1000,
|
||||
});
|
||||
|
||||
// Storage is shared by every RomM account in the browser, so a game's saves
|
||||
// live there only while it runs: synced in before, and taken back out after.
|
||||
async function prepareSaves(target: DetailedRom) {
|
||||
// Left behind by a page that went away mid-game, or from before sync.
|
||||
const leftover = storedSaves();
|
||||
try {
|
||||
const saves = await saveSync.prepare(target, saveFilesOf(target, leftover));
|
||||
if (!saves) return;
|
||||
removeRuffleSaves(host, Object.keys(leftover));
|
||||
const synced = saves.find((save) => save.slot === AUTOSAVE_SLOT);
|
||||
if (synced) {
|
||||
writeRuffleSaves(
|
||||
host,
|
||||
unzipRuffleSaves(synced.bytes, swfStoragePath(swfUrl)),
|
||||
);
|
||||
}
|
||||
// What sync just restored needs no capture of its own.
|
||||
lastRead = lastCaptured = readRuffleStorage(host, swfStoragePath(swfUrl));
|
||||
saveSync.start();
|
||||
} catch (error) {
|
||||
console.error("[Ruffle] Saves are unavailable", error);
|
||||
}
|
||||
}
|
||||
|
||||
const ruffle = window.RufflePlayer.newest();
|
||||
if (!ruffle) {
|
||||
abortPlay();
|
||||
return;
|
||||
}
|
||||
function mountPlayer(): boolean {
|
||||
const ruffle = window.RufflePlayer.newest();
|
||||
const container = document.getElementById("r-v2-ruffle-stage");
|
||||
if (!ruffle || !container) return false;
|
||||
|
||||
const player = ruffle.createPlayer();
|
||||
const container = document.getElementById("r-v2-ruffle-stage");
|
||||
if (!container) {
|
||||
abortPlay();
|
||||
return;
|
||||
}
|
||||
container.appendChild(player);
|
||||
player.load({
|
||||
allowFullScreen: true,
|
||||
autoplay: "on",
|
||||
backgroundColor: backgroundColor.value,
|
||||
forceAlign: true,
|
||||
forceScale: true,
|
||||
letterbox: "on",
|
||||
openUrlMode: "confirm",
|
||||
publicPath: "/assets/ruffle/",
|
||||
url: getDownloadPath({ rom: rom.value, purpose: "play" }),
|
||||
});
|
||||
player.style.width = "100%";
|
||||
player.style.height = "100%";
|
||||
|
||||
// Start timing the session only once playback is actually under way, so a
|
||||
// failed player creation / load records nothing. The session is ingested
|
||||
// on unmount, which is what updates last_played / now_playing / status.
|
||||
playSession.start(rom.value);
|
||||
|
||||
if (player.fullscreenEnabled && fullscreenOnPlay.value) {
|
||||
player.enterFullscreen();
|
||||
}
|
||||
const created = ruffle.createPlayer();
|
||||
container.appendChild(created);
|
||||
created.load({
|
||||
allowFullScreen: true,
|
||||
autoplay: "on",
|
||||
backgroundColor: backgroundColor.value,
|
||||
forceAlign: true,
|
||||
forceScale: true,
|
||||
letterbox: "on",
|
||||
openUrlMode: "confirm",
|
||||
publicPath: "/assets/ruffle/",
|
||||
url: swfUrl,
|
||||
});
|
||||
created.style.width = "100%";
|
||||
created.style.height = "100%";
|
||||
player = created;
|
||||
saveSync.resume();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Ruffle writes every SharedObject as its instance goes.
|
||||
function destroyPlayer() {
|
||||
saveSync.pause();
|
||||
player?.remove();
|
||||
player = null;
|
||||
}
|
||||
|
||||
async function onPlay() {
|
||||
const target = rom.value;
|
||||
if (!target || gameRunning.value) return;
|
||||
gameRunning.value = true;
|
||||
swfUrl = getDownloadPath({ rom: target, purpose: "play" });
|
||||
|
||||
await prepareSaves(target);
|
||||
if (!alive.value || !gameRunning.value) return;
|
||||
await nextTick();
|
||||
|
||||
if (!mountPlayer()) {
|
||||
abortPlay();
|
||||
return;
|
||||
}
|
||||
|
||||
// Start timing the session only once playback is actually under way, so a
|
||||
// failed player creation / load records nothing. The session is ingested
|
||||
// on unmount, which is what updates last_played / now_playing / status.
|
||||
playSession.start(target);
|
||||
|
||||
if (player?.fullscreenEnabled && fullscreenOnPlay.value) {
|
||||
player.enterFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
async function leavePlayer(leave: () => void) {
|
||||
const target = rom.value;
|
||||
if (quitting.value || !target) return;
|
||||
quitting.value = true;
|
||||
|
||||
destroyPlayer();
|
||||
if (!(await saveSync.flush())) {
|
||||
if (!(await saveSync.confirmDiscard())) {
|
||||
// The player is gone, so staying restarts the game on its saves.
|
||||
mountPlayer();
|
||||
quitting.value = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (saveSync.isActive()) removeRuffleSaves(host, Object.keys(storedSaves()));
|
||||
saveSync.stop();
|
||||
gameRunning.value = false;
|
||||
quitting.value = false;
|
||||
leave();
|
||||
}
|
||||
|
||||
function onBackgroundColorChange() {
|
||||
@@ -117,9 +249,19 @@ function onBackgroundColorChange() {
|
||||
}
|
||||
|
||||
function onlyQuit() {
|
||||
window.history.back();
|
||||
if (!player) {
|
||||
window.history.back();
|
||||
return;
|
||||
}
|
||||
void leavePlayer(() => window.history.back());
|
||||
}
|
||||
|
||||
onBeforeRouteLeave((to) => {
|
||||
if (!player) return exit.guard(to);
|
||||
void leavePlayer(() => exit.leave(to.fullPath));
|
||||
return false;
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
const romResponse = await romApi.getRom({ romId });
|
||||
rom.value = romResponse.data;
|
||||
@@ -144,6 +286,7 @@ onMounted(async () => {
|
||||
onBeforeUnmount(() => {
|
||||
// Every exit path (Quit, back links, route change) unmounts the view, so
|
||||
// this is the single choke point for recording the session.
|
||||
saveSync.stop();
|
||||
playSession.flush();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { flushPromises, mount } from "@vue/test-utils";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DeviceSchema } from "@/__generated__";
|
||||
import deviceApi from "@/services/api/device";
|
||||
import storeAuth from "@/stores/auth";
|
||||
import { userFixture } from "@/utils/user.fixtures";
|
||||
import Devices from "./Devices.vue";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
confirm: vi.fn(),
|
||||
snackbarError: vi.fn(),
|
||||
snackbarSuccess: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n");
|
||||
vi.mock("@/services/api/device", () => ({
|
||||
default: {
|
||||
fetchDevices: vi.fn(),
|
||||
updateDevice: vi.fn(),
|
||||
deleteDevice: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/v2/composables/useConfirm", () => ({
|
||||
useConfirm: () => mocks.confirm,
|
||||
}));
|
||||
vi.mock("@/v2/composables/useSnackbar", () => ({
|
||||
useSnackbar: () => ({
|
||||
success: mocks.snackbarSuccess,
|
||||
error: mocks.snackbarError,
|
||||
}),
|
||||
}));
|
||||
|
||||
function device(overrides: Partial<DeviceSchema> = {}): DeviceSchema {
|
||||
return {
|
||||
id: "dev-a",
|
||||
user_id: 1,
|
||||
name: "Firefox on macOS",
|
||||
platform: "Web",
|
||||
client: "web",
|
||||
client_version: null,
|
||||
ip_address: null,
|
||||
mac_address: null,
|
||||
hostname: null,
|
||||
client_device_identifier: null,
|
||||
sync_mode: "api",
|
||||
sync_enabled: true,
|
||||
sync_config: null,
|
||||
capabilities: null,
|
||||
last_seen: "2026-10-01T00:00:00Z",
|
||||
created_at: "2026-09-01T00:00:00Z",
|
||||
updated_at: "2026-09-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const RTable = {
|
||||
props: {
|
||||
items: { type: Array, default: () => [] },
|
||||
emptyMessage: { type: String, default: "" },
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<div v-for="row in items" :key="row.id" class="row" :data-id="row.id">
|
||||
<slot name="cell.name" :row="row" />
|
||||
<slot name="cell.last_seen" :row="row" />
|
||||
<slot name="cell.sync" :row="row" />
|
||||
<slot name="cell.actions" :row="row" />
|
||||
</div>
|
||||
<p v-if="!items.length" class="empty">{{ emptyMessage }}</p>
|
||||
</div>`,
|
||||
};
|
||||
|
||||
const RSwitch = {
|
||||
props: {
|
||||
modelValue: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false },
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
template: `<button type="button" class="switch" :data-on="modelValue" :disabled="disabled" @click="$emit('update:modelValue', !modelValue)" />`,
|
||||
};
|
||||
|
||||
const RBtn = {
|
||||
props: { icon: { type: String, default: "" } },
|
||||
emits: ["click"],
|
||||
template: `<button type="button" :data-icon="icon" @click="$emit('click')"><slot /></button>`,
|
||||
};
|
||||
|
||||
const RenameDeviceDialog = {
|
||||
name: "RenameDeviceDialog",
|
||||
props: {
|
||||
modelValue: { type: Boolean, default: false },
|
||||
name: { type: String, default: "" },
|
||||
},
|
||||
emits: ["update:modelValue", "submit"],
|
||||
template: `<div class="rename" :data-open="modelValue" :data-name="name" />`,
|
||||
};
|
||||
|
||||
function signIn(scopes: string[]) {
|
||||
storeAuth().setCurrentUser(userFixture({ id: 1, oauth_scopes: scopes }));
|
||||
}
|
||||
|
||||
function mountDevices() {
|
||||
return mount(Devices, {
|
||||
global: {
|
||||
stubs: {
|
||||
RTable,
|
||||
RSwitch,
|
||||
RBtn,
|
||||
RChip: { template: "<span class='chip'><slot /></span>" },
|
||||
RenameDeviceDialog,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function row(wrapper: ReturnType<typeof mountDevices>, id: string) {
|
||||
return wrapper.get(`.row[data-id='${id}']`);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
signIn(["devices.read", "devices.write"]);
|
||||
vi.mocked(deviceApi.fetchDevices).mockResolvedValue({
|
||||
data: [
|
||||
device({
|
||||
id: "dev-old",
|
||||
name: "Steam Deck",
|
||||
platform: "Linux",
|
||||
client: "argosy",
|
||||
last_seen: "2026-01-01T00:00:00Z",
|
||||
}),
|
||||
device(),
|
||||
],
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("Devices", () => {
|
||||
it("lists the most recently active first and marks this browser", async () => {
|
||||
localStorage.setItem("romm:browser-device:1", "dev-a");
|
||||
const wrapper = mountDevices();
|
||||
await flushPromises();
|
||||
|
||||
const ids = wrapper.findAll(".row").map((r) => r.attributes("data-id"));
|
||||
expect(ids).toEqual(["dev-a", "dev-old"]);
|
||||
expect(row(wrapper, "dev-a").find(".chip").text()).toBe(
|
||||
"settings.device-this-browser",
|
||||
);
|
||||
expect(row(wrapper, "dev-old").find(".chip").exists()).toBe(false);
|
||||
expect(row(wrapper, "dev-old").text()).toContain("Linux · argosy");
|
||||
});
|
||||
|
||||
it("shows the empty state when there are no devices", async () => {
|
||||
vi.mocked(deviceApi.fetchDevices).mockResolvedValue({ data: [] } as never);
|
||||
const wrapper = mountDevices();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.get(".empty").text()).toBe("settings.devices-empty");
|
||||
});
|
||||
|
||||
it("turns sync off at once and keeps it off once saved", async () => {
|
||||
vi.mocked(deviceApi.updateDevice).mockResolvedValue({
|
||||
data: device({ sync_enabled: false }),
|
||||
} as never);
|
||||
const wrapper = mountDevices();
|
||||
await flushPromises();
|
||||
|
||||
await row(wrapper, "dev-a").get(".switch").trigger("click");
|
||||
expect(row(wrapper, "dev-a").get(".switch").attributes("data-on")).toBe(
|
||||
"false",
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
expect(deviceApi.updateDevice).toHaveBeenCalledWith("dev-a", {
|
||||
sync_enabled: false,
|
||||
});
|
||||
expect(row(wrapper, "dev-a").get(".switch").attributes("data-on")).toBe(
|
||||
"false",
|
||||
);
|
||||
});
|
||||
|
||||
it("restores the switch when the change fails", async () => {
|
||||
vi.mocked(deviceApi.updateDevice).mockRejectedValue(new Error("offline"));
|
||||
const wrapper = mountDevices();
|
||||
await flushPromises();
|
||||
|
||||
await row(wrapper, "dev-a").get(".switch").trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(row(wrapper, "dev-a").get(".switch").attributes("data-on")).toBe(
|
||||
"true",
|
||||
);
|
||||
expect(mocks.snackbarError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renames a device", async () => {
|
||||
vi.mocked(deviceApi.updateDevice).mockResolvedValue({
|
||||
data: device({ name: "Living room" }),
|
||||
} as never);
|
||||
const wrapper = mountDevices();
|
||||
await flushPromises();
|
||||
|
||||
await row(wrapper, "dev-a")
|
||||
.get("[data-icon='mdi-pencil-outline']")
|
||||
.trigger("click");
|
||||
const dialog = wrapper.findComponent(RenameDeviceDialog);
|
||||
expect(dialog.props()).toMatchObject({
|
||||
modelValue: true,
|
||||
name: "Firefox on macOS",
|
||||
});
|
||||
dialog.vm.$emit("submit", "Living room");
|
||||
await flushPromises();
|
||||
|
||||
expect(deviceApi.updateDevice).toHaveBeenCalledWith("dev-a", {
|
||||
name: "Living room",
|
||||
});
|
||||
expect(row(wrapper, "dev-a").text()).toContain("Living room");
|
||||
expect(dialog.props("modelValue")).toBe(false);
|
||||
});
|
||||
|
||||
it("deletes a device only once confirmed", async () => {
|
||||
vi.mocked(deviceApi.deleteDevice).mockResolvedValue({} as never);
|
||||
const wrapper = mountDevices();
|
||||
await flushPromises();
|
||||
const deleteButton = () =>
|
||||
row(wrapper, "dev-old").get("[data-icon='mdi-trash-can-outline']");
|
||||
|
||||
mocks.confirm.mockResolvedValueOnce(false);
|
||||
await deleteButton().trigger("click");
|
||||
await flushPromises();
|
||||
expect(deviceApi.deleteDevice).not.toHaveBeenCalled();
|
||||
|
||||
mocks.confirm.mockResolvedValueOnce(true);
|
||||
await deleteButton().trigger("click");
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.confirm).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ tone: "danger" }),
|
||||
);
|
||||
expect(deviceApi.deleteDevice).toHaveBeenCalledWith("dev-old");
|
||||
expect(wrapper.find(".row[data-id='dev-old']").exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("is read-only without devices.write", async () => {
|
||||
signIn(["devices.read"]);
|
||||
const wrapper = mountDevices();
|
||||
await flushPromises();
|
||||
|
||||
expect(
|
||||
row(wrapper, "dev-a").get(".switch").attributes("disabled"),
|
||||
).toBeDefined();
|
||||
expect(row(wrapper, "dev-a").find("[data-icon]").exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
<script setup lang="ts">
|
||||
import { RBtn, RChip, RSwitch, RTable, type RTableColumn } from "@v2/lib";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { DeviceSchema } from "@/__generated__";
|
||||
import deviceApi from "@/services/api/device";
|
||||
import storeAuth from "@/stores/auth";
|
||||
import { formatRelativeDate } from "@/utils";
|
||||
import RenameDeviceDialog from "@/v2/components/Settings/RenameDeviceDialog.vue";
|
||||
import { useConfirm } from "@/v2/composables/useConfirm";
|
||||
import { useFetchState } from "@/v2/composables/useFetchState";
|
||||
import { useSnackbar } from "@/v2/composables/useSnackbar";
|
||||
import { errorMessage } from "@/v2/utils/errorMessage";
|
||||
import { cachedBrowserDeviceId } from "@/v2/utils/saveSync/browserDevice";
|
||||
|
||||
const { t } = useI18n();
|
||||
const snackbar = useSnackbar();
|
||||
const confirm = useConfirm();
|
||||
const { user, scopes } = storeToRefs(storeAuth());
|
||||
const canWrite = computed(() => scopes.value.includes("devices.write"));
|
||||
|
||||
const { state: devices, isLoading: loading } = useFetchState(
|
||||
() => deviceApi.fetchDevices().then(({ data }) => data),
|
||||
[],
|
||||
{ onError: (error) => console.error(error) },
|
||||
);
|
||||
|
||||
const thisBrowser = computed(() =>
|
||||
user.value ? cachedBrowserDeviceId(user.value.id) : null,
|
||||
);
|
||||
|
||||
// Most recently active first; devices never seen sink to the end.
|
||||
const sortedDevices = computed(() =>
|
||||
[...devices.value].sort((a, b) =>
|
||||
(b.last_seen ?? "").localeCompare(a.last_seen ?? ""),
|
||||
),
|
||||
);
|
||||
|
||||
const columns = computed<RTableColumn[]>(() => [
|
||||
{
|
||||
key: "name",
|
||||
label: t("common.name"),
|
||||
width: "minmax(0, 2fr)",
|
||||
skeletonWidth: 160,
|
||||
},
|
||||
{
|
||||
key: "last_seen",
|
||||
label: t("settings.device-last-seen"),
|
||||
width: "minmax(0, 1fr)",
|
||||
skeletonWidth: 100,
|
||||
},
|
||||
{
|
||||
key: "sync",
|
||||
label: t("settings.device-sync"),
|
||||
width: "96px",
|
||||
skeletonWidth: 36,
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
label: "",
|
||||
width: "96px",
|
||||
align: "end",
|
||||
skeletonWidth: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
function displayName(device: DeviceSchema): string {
|
||||
return (
|
||||
device.name ||
|
||||
device.platform ||
|
||||
device.client ||
|
||||
t("settings.device-unnamed")
|
||||
);
|
||||
}
|
||||
|
||||
function detailLine(device: DeviceSchema): string {
|
||||
return [device.platform, device.client, device.client_version]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
function replace(updated: DeviceSchema) {
|
||||
devices.value = devices.value.map((d) => (d.id === updated.id ? updated : d));
|
||||
}
|
||||
|
||||
async function toggleSync(device: DeviceSchema, enabled: boolean) {
|
||||
replace({ ...device, sync_enabled: enabled });
|
||||
try {
|
||||
const { data } = await deviceApi.updateDevice(device.id, {
|
||||
sync_enabled: enabled,
|
||||
});
|
||||
replace(data);
|
||||
} catch (error) {
|
||||
replace(device);
|
||||
snackbar.error(
|
||||
t("settings.device-update-failed", { detail: errorMessage(error) }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const renaming = ref<DeviceSchema | null>(null);
|
||||
const renameOpen = ref(false);
|
||||
const renameBusy = ref(false);
|
||||
|
||||
function openRename(device: DeviceSchema) {
|
||||
renaming.value = device;
|
||||
renameOpen.value = true;
|
||||
}
|
||||
|
||||
async function rename(name: string) {
|
||||
const device = renaming.value;
|
||||
if (!device) return;
|
||||
renameBusy.value = true;
|
||||
try {
|
||||
const { data } = await deviceApi.updateDevice(device.id, { name });
|
||||
replace(data);
|
||||
renameOpen.value = false;
|
||||
} catch (error) {
|
||||
snackbar.error(
|
||||
t("settings.device-update-failed", { detail: errorMessage(error) }),
|
||||
);
|
||||
} finally {
|
||||
renameBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(device: DeviceSchema) {
|
||||
const ok = await confirm({
|
||||
title: t("common.confirm-deletion"),
|
||||
body: t("settings.device-confirm-delete", { name: displayName(device) }),
|
||||
confirmText: t("common.delete"),
|
||||
tone: "danger",
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await deviceApi.deleteDevice(device.id);
|
||||
devices.value = devices.value.filter((d) => d.id !== device.id);
|
||||
snackbar.success(t("settings.device-deleted"), { icon: "mdi-check-bold" });
|
||||
} catch (error) {
|
||||
snackbar.error(
|
||||
t("settings.device-delete-failed", { detail: errorMessage(error) }),
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="r-v2-section-stack">
|
||||
<p class="r-v2-devices__intro">{{ t("settings.devices-description") }}</p>
|
||||
|
||||
<RTable
|
||||
:columns="columns"
|
||||
:items="sortedDevices"
|
||||
:item-key="(r) => (r as DeviceSchema).id"
|
||||
:loading="loading"
|
||||
empty-icon="mdi-devices"
|
||||
:empty-message="t('settings.devices-empty')"
|
||||
>
|
||||
<template #cell.name="{ row }">
|
||||
<div class="r-v2-devices__name-cell">
|
||||
<div class="r-v2-devices__name-line">
|
||||
<span class="r-v2-devices__name">
|
||||
{{ displayName(row as DeviceSchema) }}
|
||||
</span>
|
||||
<RChip
|
||||
v-if="(row as DeviceSchema).id === thisBrowser"
|
||||
size="x-small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
>
|
||||
{{ t("settings.device-this-browser") }}
|
||||
</RChip>
|
||||
</div>
|
||||
<span
|
||||
v-if="detailLine(row as DeviceSchema)"
|
||||
class="r-v2-devices__meta"
|
||||
>
|
||||
{{ detailLine(row as DeviceSchema) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell.last_seen="{ row }">
|
||||
<span class="r-v2-devices__meta">
|
||||
{{
|
||||
(row as DeviceSchema).last_seen
|
||||
? formatRelativeDate((row as DeviceSchema).last_seen!)
|
||||
: "—"
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
<template #cell.sync="{ row }">
|
||||
<RSwitch
|
||||
:model-value="(row as DeviceSchema).sync_enabled"
|
||||
:disabled="!canWrite"
|
||||
:aria-label="t('settings.device-sync')"
|
||||
@update:model-value="(v) => toggleSync(row as DeviceSchema, v)"
|
||||
/>
|
||||
</template>
|
||||
<template #cell.actions="{ row }">
|
||||
<div v-if="canWrite" class="r-v2-devices__actions">
|
||||
<RBtn
|
||||
variant="text"
|
||||
size="small"
|
||||
icon="mdi-pencil-outline"
|
||||
:aria-label="t('settings.device-rename')"
|
||||
:title="t('settings.device-rename')"
|
||||
@click="openRename(row as DeviceSchema)"
|
||||
/>
|
||||
<RBtn
|
||||
variant="text"
|
||||
size="small"
|
||||
icon="mdi-trash-can-outline"
|
||||
color="danger"
|
||||
:aria-label="t('common.delete')"
|
||||
:title="t('common.delete')"
|
||||
class="r-v2-devices__delete"
|
||||
@click="remove(row as DeviceSchema)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</RTable>
|
||||
|
||||
<RenameDeviceDialog
|
||||
v-model="renameOpen"
|
||||
:name="renaming ? displayName(renaming) : ''"
|
||||
:busy="renameBusy"
|
||||
@submit="rename"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.r-v2-devices__intro {
|
||||
margin: 0;
|
||||
color: var(--r-color-fg-muted);
|
||||
font-size: var(--r-font-size-sm);
|
||||
}
|
||||
|
||||
.r-v2-devices__name-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.r-v2-devices__name-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--r-space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.r-v2-devices__name {
|
||||
font-weight: var(--r-font-weight-semibold);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.r-v2-devices__meta {
|
||||
color: var(--r-color-fg-muted);
|
||||
font-size: var(--r-font-size-xs);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.r-v2-devices__actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.r-v2-devices__delete {
|
||||
--r-btn-color: color-mix(in srgb, var(--r-color-danger) 70%, transparent);
|
||||
}
|
||||
.r-v2-devices__delete:hover {
|
||||
--r-btn-color: var(--r-color-danger);
|
||||
}
|
||||
</style>
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
import { userStorage } from "@/composables/useUserLocalStorage";
|
||||
import { ROUTES } from "@/plugins/router";
|
||||
import { saveApi as api } from "@/services/api/save";
|
||||
import syncApi from "@/services/api/sync";
|
||||
import pendingAssetStore, {
|
||||
pendingAssetId,
|
||||
type PendingAsset,
|
||||
@@ -39,8 +40,12 @@ import {
|
||||
getDownloadPath,
|
||||
} from "@/utils";
|
||||
import { useSnackbar, type SnackbarTone } from "@/v2/composables/useSnackbar";
|
||||
import { newest } from "@/v2/utils/assets";
|
||||
import { firmwareExternalFiles } from "@/v2/utils/playerFirmware";
|
||||
import { leaveFullscreen } from "@/v2/utils/playerFullscreen";
|
||||
import { browserDeviceId } from "@/v2/utils/saveSync/browserDevice";
|
||||
import { bytesEqual, saveContentHash } from "@/v2/utils/saveSync/hash";
|
||||
import { createRetryBackoff } from "@/v2/utils/saveSync/retryBackoff";
|
||||
import {
|
||||
saveSave,
|
||||
captureScreenshot,
|
||||
@@ -60,9 +65,7 @@ import {
|
||||
createSaveQuitButton,
|
||||
createExitEmulationButton,
|
||||
labelContextMenuButton,
|
||||
createRetryBackoff,
|
||||
createSaveSyncTracker,
|
||||
bytesEqual,
|
||||
pollSaveFiles,
|
||||
saveSaveOnUnload,
|
||||
toArrayBuffer,
|
||||
@@ -97,7 +100,28 @@ const romRef = ref<DetailedRom>(props.rom);
|
||||
// so far. Loading a save resets the version so the next write opens a new one.
|
||||
let loadedSave: SaveSchema | null = props.save;
|
||||
const sessionSaveRef = ref<SaveSchema | null>(null);
|
||||
const deviceIDRef = ref(authStore.user?.current_device_id ?? undefined);
|
||||
// This browser's own sync device: the login's device is shared by every
|
||||
// browser behind one address. Undefined when the account cannot have one.
|
||||
const deviceIDRef = ref<string | undefined>(undefined);
|
||||
const deviceReady = (async () => {
|
||||
const userId = authStore.user?.id;
|
||||
if (userId == null) return;
|
||||
try {
|
||||
deviceIDRef.value = (await browserDeviceId(userId)) ?? undefined;
|
||||
} catch (error) {
|
||||
console.error("Registering this browser as a sync device failed", error);
|
||||
}
|
||||
})();
|
||||
// Writes that continue the slot's current version are refused when another
|
||||
// device moved the slot on since; any other boot source was picked on purpose.
|
||||
function continuesSlotHead(): boolean {
|
||||
const slot = loadedSave?.slot;
|
||||
if (!slot) return false;
|
||||
const head = newest(
|
||||
romRef.value.user_saves.filter((save) => save.slot === slot),
|
||||
);
|
||||
return head?.id === loadedSave?.id;
|
||||
}
|
||||
// Bytes the server already holds, so forced writes can skip an unchanged SRAM.
|
||||
const saveTracker = createSaveSyncTracker();
|
||||
function baselineSaveTrackerFromEmulator() {
|
||||
@@ -171,6 +195,7 @@ function writeSave(
|
||||
): Promise<SaveSchema | null> {
|
||||
if (saveLoading) return Promise.resolve(null);
|
||||
const write = saveWrite.then(async () => {
|
||||
await deviceReady;
|
||||
if (generation !== saveGeneration) return null;
|
||||
const bytes = new Uint8Array(file.saveFile);
|
||||
inFlightSave = bytes;
|
||||
@@ -190,6 +215,7 @@ function writeSave(
|
||||
save: sessionSaveRef.value,
|
||||
deviceId: deviceIDRef.value,
|
||||
slot: currentSlot(),
|
||||
guarded: !!deviceIDRef.value && continuesSlotHead(),
|
||||
saveFile: file.saveFile,
|
||||
screenshotFile,
|
||||
});
|
||||
@@ -663,13 +689,26 @@ async function loadSave(
|
||||
saveLoading = true;
|
||||
|
||||
try {
|
||||
await deviceReady;
|
||||
const deviceId = deviceIDRef.value;
|
||||
const { data } = await api.get(save.download_path.replace("/api", ""), {
|
||||
responseType: "arraybuffer",
|
||||
params: { device_id: deviceIDRef.value },
|
||||
params: { device_id: deviceId },
|
||||
});
|
||||
if (disposed || generation !== saveGeneration) return false;
|
||||
const bytes = new Uint8Array(data);
|
||||
apply(bytes);
|
||||
if (deviceId) {
|
||||
void syncApi
|
||||
.confirmDownloaded({
|
||||
saveId: save.id,
|
||||
deviceId,
|
||||
contentHash: saveContentHash(bytes),
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error("Confirming the loaded save failed", error);
|
||||
});
|
||||
}
|
||||
// Writes follow the picked save only once its bytes are in the core.
|
||||
loadedSave = save;
|
||||
sessionSaveRef.value = null;
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { AxiosError, AxiosHeaders } from "axios";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SaveSchema, StateSchema } from "@/__generated__";
|
||||
import { sessionStateName } from "@/services/api/state";
|
||||
import type { DetailedRom } from "@/stores/roms";
|
||||
import { saveContentHash } from "@/v2/utils/saveSync/hash";
|
||||
import {
|
||||
buildStateFormData,
|
||||
captureScreenshot,
|
||||
dumpSaveFile,
|
||||
heldFor,
|
||||
createRetryBackoff,
|
||||
createSaveSyncTracker,
|
||||
RETRY_BACKOFF_MAX_MS,
|
||||
RETRY_BACKOFF_MIN_MS,
|
||||
bootEmulatorJSSave,
|
||||
installEJSDefaultOptionsTrap,
|
||||
installNetplayHostAudioTap,
|
||||
@@ -636,54 +635,6 @@ describe("createSaveSyncTracker", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pollSaveFiles", () => {
|
||||
const emulatorWith = (sramBytes: number) => ({
|
||||
started: true,
|
||||
@@ -1174,11 +1125,51 @@ describe("saveSave", () => {
|
||||
slot: "autosave",
|
||||
autocleanup: true,
|
||||
overwrite: true,
|
||||
contentHash: saveContentHash(new Uint8Array(bytes)),
|
||||
}),
|
||||
);
|
||||
expect(rom.user_saves).toEqual([{ id: 2, slot: "autosave" }]);
|
||||
});
|
||||
|
||||
it("guards a version that continues the slot's current one", async () => {
|
||||
await saveSave({ rom, save: null, saveFile: bytes, guarded: true });
|
||||
|
||||
expect(saveApiMocks.uploadSaves).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ slot: "autosave", overwrite: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("archives the progress when another device moved the slot on", async () => {
|
||||
const conflict = new AxiosError("conflict", "ERR", undefined, undefined, {
|
||||
status: 409,
|
||||
statusText: "",
|
||||
data: null,
|
||||
headers: {},
|
||||
config: { headers: new AxiosHeaders() },
|
||||
});
|
||||
const archived = { id: 8, slot: null } as SaveSchema;
|
||||
saveApiMocks.uploadSaves
|
||||
.mockResolvedValueOnce([{ status: "rejected", reason: conflict }])
|
||||
.mockResolvedValueOnce([{ status: "fulfilled", value: archived }]);
|
||||
|
||||
const save = await saveSave({
|
||||
rom,
|
||||
save: null,
|
||||
saveFile: bytes,
|
||||
deviceId: "device-1",
|
||||
guarded: true,
|
||||
});
|
||||
|
||||
const archive = saveApiMocks.uploadSaves.mock.calls[1]![0];
|
||||
expect(archive.slot).toBeUndefined();
|
||||
expect(archive.deviceId).toBe("device-1");
|
||||
expect(archive.savesToUpload[0].saveFile.name).toMatch(
|
||||
/^game \[.+\]\.srm$/,
|
||||
);
|
||||
expect(save).toBe(archived);
|
||||
expect(rom.user_saves).toEqual([archived]);
|
||||
});
|
||||
|
||||
it("leaves the datetime tag of a slotted upload to the backend", async () => {
|
||||
await saveSave({ rom, save: null, saveFile: bytes });
|
||||
|
||||
|
||||
@@ -11,13 +11,16 @@ import saveApi, {
|
||||
} from "@/services/api/save";
|
||||
import stateApi, { sessionStateFiles } from "@/services/api/state";
|
||||
import pendingAssetStore, {
|
||||
isSlotConflict,
|
||||
pendingAssetId,
|
||||
uploadArchivedSave,
|
||||
type PendingAsset,
|
||||
} from "@/services/pending-asset";
|
||||
import storeHeartbeat from "@/stores/heartbeat";
|
||||
import { type DetailedRom } from "@/stores/roms";
|
||||
import { buildFormInput } from "@/utils/formData";
|
||||
import { keepArcadeBiosWhole } from "@/v2/utils/playerFirmware";
|
||||
import { bytesEqual, saveContentHash } from "@/v2/utils/saveSync/hash";
|
||||
|
||||
/** Tears the emulator down once, however many owners ask. */
|
||||
export function exitEmulatorOnce() {
|
||||
@@ -192,8 +195,8 @@ export async function saveState({
|
||||
return { state: null, kept };
|
||||
}
|
||||
|
||||
// `save` is the version this session already created: it is updated in place,
|
||||
// while a null `save` opens a new version in `slot`.
|
||||
// A non-null `save` is updated in place; otherwise a version opens in `slot`,
|
||||
// archived instead when `guarded` and another device moved the slot on.
|
||||
export async function saveSave({
|
||||
rom,
|
||||
save,
|
||||
@@ -201,6 +204,7 @@ export async function saveSave({
|
||||
screenshotFile,
|
||||
deviceId,
|
||||
slot = AUTOSAVE_SLOT,
|
||||
guarded = false,
|
||||
}: {
|
||||
rom: DetailedRom;
|
||||
save: SaveSchema | null;
|
||||
@@ -208,6 +212,7 @@ export async function saveSave({
|
||||
screenshotFile?: ArrayBuffer | undefined;
|
||||
deviceId?: string | undefined;
|
||||
slot?: string | undefined;
|
||||
guarded?: boolean;
|
||||
}): Promise<SaveSchema | null> {
|
||||
if (save) {
|
||||
try {
|
||||
@@ -218,6 +223,7 @@ export async function saveSave({
|
||||
? sessionScreenshotFile(rom, save, screenshotFile)
|
||||
: undefined,
|
||||
deviceId,
|
||||
contentHash: saveContentHash(new Uint8Array(saveFile)),
|
||||
});
|
||||
|
||||
const index = rom.user_saves.findIndex((s) => s.id === updatedSave.id);
|
||||
@@ -241,9 +247,10 @@ export async function saveSave({
|
||||
// Like Argosy: the autosave slot keeps a capped history, named slots
|
||||
// keep every version.
|
||||
autocleanup: slot === AUTOSAVE_SLOT,
|
||||
// The boot source is an explicit choice on the launch screen, so neither
|
||||
// the stale-device guard nor the hash dedupe applies (callers skip dupes).
|
||||
overwrite: true,
|
||||
// Booting anything but the slot's current version is an explicit choice
|
||||
// on the launch screen, which neither the guard nor the dedupe second-guess.
|
||||
overwrite: !guarded,
|
||||
contentHash: saveContentHash(new Uint8Array(saveFile)),
|
||||
savesToUpload: [
|
||||
{
|
||||
saveFile: sessionSaveFile(rom, null, saveFile),
|
||||
@@ -254,7 +261,20 @@ export async function saveSave({
|
||||
],
|
||||
});
|
||||
|
||||
const uploadedSave = uploadedSaves[0]!;
|
||||
let uploadedSave = uploadedSaves[0]!;
|
||||
if (isSlotConflict(uploadedSave)) {
|
||||
// Later writes update the archive in place, leaving the slot to the
|
||||
// other device's newer version.
|
||||
uploadedSave =
|
||||
(await uploadArchivedSave({
|
||||
rom,
|
||||
emulator: window.EJS_core,
|
||||
deviceId,
|
||||
capturedAt: new Date(),
|
||||
bytes: saveFile,
|
||||
screenshotBytes: screenshotFile,
|
||||
})) ?? uploadedSave;
|
||||
}
|
||||
if (uploadedSave.status == "fulfilled") {
|
||||
if (rom) rom.user_saves.unshift(uploadedSave.value);
|
||||
return uploadedSave.value;
|
||||
@@ -289,6 +309,7 @@ export function saveSaveOnUnload({
|
||||
deviceId,
|
||||
slot,
|
||||
autocleanup: slot === AUTOSAVE_SLOT,
|
||||
contentHash: saveContentHash(new Uint8Array(saveFile)),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -336,43 +357,6 @@ export function createSaveSyncTracker() {
|
||||
};
|
||||
}
|
||||
|
||||
// The tick re-offers a failed upload every second, 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// EmulatorJS reads each tick off the FS into a fresh buffer, so the tracker can
|
||||
// hold on to one rather than fingerprint it.
|
||||
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;
|
||||
}
|
||||
|
||||
// The core exposes no write hook for its SRAM and EmulatorJS flushes it only on
|
||||
// its "System Save interval" (5 minutes by default), so polling it every second
|
||||
// is what gets an in-game save to the server right after the game writes it.
|
||||
|
||||
Reference in New Issue
Block a user