From 61642c251dcc204af57e2f794cec29396f47fadb Mon Sep 17 00:00:00 2001 From: Erick Sanchez <7268597+nalancer08@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:35:34 -0600 Subject: [PATCH] Add four named touch-control profiles and portable save API foundation --- .github/requirements/lint.txt | 3 +- .github/workflows/tests.yml | 14 ++ README-es.md | 6 +- README.md | 6 +- desktop/release.json | 4 +- dm/cloud/README.md | 140 ++++++++++++++++++ dm/cloud/requirements.txt | 1 + dm/cloud/save_api.py | 237 ++++++++++++++++++++++++++++++ dm/ios/Sources/DmGamepad.m | 95 +++++++++--- dm/ios/Sources/DmPadProfiles.h | 83 +++++++++++ dm/ios/tests/pad_profiles_check.m | 44 ++++++ dm/ios/tests/test_pad_profiles.py | 15 ++ docs/personal-ios.md | 34 ++++- pyproject.toml | 6 +- scripts/run_save_api.py | 35 +++++ scripts/tests/test_save_api.py | 146 ++++++++++++++++++ 16 files changed, 837 insertions(+), 32 deletions(-) create mode 100644 dm/cloud/README.md create mode 100644 dm/cloud/requirements.txt create mode 100644 dm/cloud/save_api.py create mode 100644 dm/ios/Sources/DmPadProfiles.h create mode 100644 dm/ios/tests/pad_profiles_check.m create mode 100644 dm/ios/tests/test_pad_profiles.py create mode 100644 scripts/run_save_api.py create mode 100644 scripts/tests/test_save_api.py diff --git a/.github/requirements/lint.txt b/.github/requirements/lint.txt index 1d9ccc4..8931ffd 100644 --- a/.github/requirements/lint.txt +++ b/.github/requirements/lint.txt @@ -1,7 +1,8 @@ # The Python lint job's tools (.github/workflows/lint-python.yml, dm/tools/lint_python.py), pinned: their findings change with their versions, so raising one -# goes with `python3 dm/tools/lint_python.py --rebaseline` in the same commit. The last three are the packages the tools import (as in tests.yml), so pyright resolves them. +# goes with `python3 dm/tools/lint_python.py --rebaseline` in the same commit. The remaining entries are packages the tools import (as in tests.yml), so pyright resolves them. ruff==0.17.2 pyright==1.1.414 lz4==4.4.5 capstone==5.0.9 fonttools==4.67.0 +PyJWT[crypto]==2.15.1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0799313..c02d4a9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -179,3 +179,17 @@ jobs: name: logs-ios-build path: logs retention-days: 7 + + save-api: + name: Save API authentication, conflicts and immutable sharing + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 + with: + python-version: '3.13' + - run: python -m pip install -r dm/cloud/requirements.txt + - run: python -m unittest scripts.tests.test_save_api diff --git a/README-es.md b/README-es.md index c64ac95..f4a831c 100644 --- a/README-es.md +++ b/README-es.md @@ -255,7 +255,11 @@ Guías de Apple para [conectar Xbox](https://support.apple.com/en-us/111101) y [ ## Controles a tu gusto -En el juego pulsa **Edit controls**. Mueve los botones, ajusta tamaño y opacidad y pulsa **Save**. **Cancel** descarta y **Reset control / Reset all** restaura. Cada juego guarda su acomodo 2D y 3D. El juego sigue corriendo mientras editas. El selector de idioma de la app de Mac no traduce este editor dentro del juego. +En el juego pulsa **Edit controls**. Mueve los botones, ajusta tamaño y opacidad y pulsa **Save**. **Cancel** descarta y **Reset control / Reset all** restaura. Cada juego tiene **cuatro perfiles con nombre**, cada uno con su acomodo 2D y 3D. Usa el menú de perfiles para cambiar o renombrar uno (por ejemplo iPhone, iPad o Grande), y **Save as…** para copiar ambos acomodos a otro espacio. Se avisa antes de descartar cambios sin guardar o sustituir un perfil. Tu acomodo anterior se conserva en Profile 1. Los perfiles se guardan localmente en cada dispositivo. El juego sigue corriendo mientras editas. El selector de idioma de la app de Mac no traduce este editor dentro del juego. + +## Prototipo de API para partidas en la nube + +Hay una [API portable de prueba](dm/cloud/README.md) con versiones privadas de partidas, detección de conflictos y enlaces para compartir con caducidad y revocación. **Todavía falta conectar el inicio de sesión, la subida y la restauración dentro del juego**; no se ha desplegado un servidor ni creado una cuenta de proveedor. Tus partidas y perfiles de controles siguen en tu dispositivo. Esto es independiente del multijugador en línea.
Guías, comandos y detalles técnicos diff --git a/README.md b/README.md index e1ba291..97b8e1a 100644 --- a/README.md +++ b/README.md @@ -253,7 +253,11 @@ See Apple's [Xbox pairing guide](https://support.apple.com/en-us/111101) and [Pl ## Make the controls your own -Inside the game, tap **Edit controls**. Move buttons, adjust their size and opacity, then tap **Save**. **Cancel** discards edits; **Reset control / Reset all** restores defaults. Each game keeps separate 2D and 3D layouts. The game continues running while you edit. The desktop language selector does not translate this in-game editor. +Inside the game, tap **Edit controls**. Move buttons, adjust their size and opacity, then tap **Save**. **Cancel** discards edits; **Reset control / Reset all** restores defaults. Each game has **four named profiles**, each with separate 2D and 3D layouts. Use the profile menu to switch or rename one (for example iPhone, iPad or Large), and **Save as…** to copy both layouts into another slot. Switching warns before discarding unsaved edits; replacing a profile asks first. Your previous layout is preserved in Profile 1. Profiles are stored locally on each device. The game continues running while you edit. The desktop language selector does not translate this in-game editor. + +## Cloud-save API prototype + +A separate [portable API prototype](dm/cloud/README.md) supports private snapshot versions, upload conflicts and expiring/revocable sharing. **Native cloud login, upload and restore are not connected yet**; no server or identity-provider account has been deployed. Game saves and control profiles continue to stay on your device. This is unrelated to online multiplayer.
Guides, commands and technical details diff --git a/desktop/release.json b/desktop/release.json index ed9c0fc..c5de4ba 100644 --- a/desktop/release.json +++ b/desktop/release.json @@ -1,5 +1,5 @@ { - "version": "1.5.0", - "build": 9, + "version": "1.6.0", + "build": 10, "minimumMacOSMajor": 26 } diff --git a/dm/cloud/README.md b/dm/cloud/README.md new file mode 100644 index 0000000..67c0654 --- /dev/null +++ b/dm/cloud/README.md @@ -0,0 +1,140 @@ +# Private save-snapshot API prototype + +This is a locally tested, provider-independent server foundation. The game apps +are **not connected to cloud login, upload or restore yet**. This module never +reads the Mac/iPhone's game folders and never installs or restores files. Do not +present it as a working cloud-save feature in the player interface. + +## Current save model + +- iOS uses `Documents/save`; games mounting separate account and device saves + also use `Documents/save_device` (`main.m`, `hle_fs.c`). Mac uses its per-title + Application Support save root; a separate device save is its `_device` sibling. +- The engine writes into `.work` directories and commits via APFS clones and an + atomic swap. Failed writes preserve the previous committed tree. Upload the + committed snapshot, not `.work`, `.new`, temporary files or the game assets. +- Some case-sensitive guest names use `.dm-case-*` physical aliases with the + `org.d2rmobile.guest-name` xattr. A generic ZIP export that drops those xattrs + can change the save's logical filenames. The portable manifest carries this + metadata explicitly for files and directories. +- Account and device save roots are independent transactions. A safe client + snapshots/restores both **before Play**, while the game is stopped. Import must + create a local recovery backup, validate the entire package, stage all trees, + use a durable restore journal with rollback/recovery, and refuse Play while a + restore is pending. Those native snapshot/import operations are still to build. +- Saves may depend on game version, region, DLC, account or game-specific IDs. + Record the exact Title ID, main build ID and version. Do not promise universal + interchange with another release or an emulator without a tested conversion. + +## What the prototype implements + +The server stores immutable versions in SQLite, scoped to the verified user's +identity plus game and named save slot. Writes need an exact prior revision +(`If-Match`) or a new-slot condition (`If-None-Match: *`). Concurrent writes from +an iPad and iPhone cannot silently overwrite one another: the stale writer gets +412 and must keep its local branch until the user chooses. Retries after an +unknown upload outcome first read the cloud head and compare, rather than force +upload. History is retained; quota errors do not delete old versions. + +Sharing pins one immutable revision. A signed-in recipient uses an expiring, +revocable token to copy that revision into their own slot. It does not grant write +access to the sender's slot; revoking a link does not erase copies already taken. +The prototype provides private endpoints, not a public gallery of save files. + +Identity is delegated to a configured provider. The server checks asymmetric +RS256/ES256 JWT signatures, issuer, audience, expiry and required claims through +PyJWT/JWKS. It keeps no passwords, email addresses or signing private keys. +Issuer and JWKS must use HTTPS on the same trusted host. There is no bypass login +or hard-coded development token in the executable. Tests inject identities only +into test instances and generate their signing keys in memory. + +For a simple native login, email OTP through a managed provider is a practical +first option; Supabase is one compatible example when its asymmetric signing keys +and correct session issuer/audience are configured. Its [passwordless auth docs](https://supabase.com/docs/guides/auth/auth-email-passwordless) +cover the code flow. Native login, secure token storage/refresh and logout remain +client work. Sign in with Apple/CloudKit would also need the corresponding Apple +capabilities and provisioning; do not assume they work under arbitrary sideloaded +bundle IDs ([Apple's CloudKit sample requirements](https://github.com/apple/sample-cloudkit-sharing)). +No provider account or external service was created by this implementation. + +## Run locally after configuring identity + +Use Python 3.10+ on the server. Keep its environment, database and backups outside +the source checkout. The server runs independently of Xcode and the game builder. + +```sh +python3 -m venv /your/private/tools/save-api-venv +/your/private/tools/save-api-venv/bin/pip install -r dm/cloud/requirements.txt +export OIDC_ISSUER="https://your-identity-provider/issuer" +export OIDC_AUDIENCE="your-save-api-audience" +export OIDC_JWKS_URL="https://your-identity-provider/issuer/jwks" +/your/private/tools/save-api-venv/bin/python scripts/run_save_api.py --data /your/private/save-api-data +``` + +It binds to `127.0.0.1:8787`. `/health` is unauthenticated; every `/v1` route needs +`Authorization: Bearer `. The sample domains above are +placeholders, not real credentials. Use a real configured provider before running. +No real saves or tokens are included in this repo. + +The local WSGI runner is for development, not a public deployment. Before exposing +it, choose hosting, a production WSGI server/TLS proxy, request rate/time/size +limits, disk quotas, encrypted storage, scheduled database backups and tested +recovery. The snapshots are not end-to-end encrypted; the server administrator +can read them. Leave native cloud buttons disabled until the identity and restore +flows are actually configured and device-tested. + +## Endpoints + +| Method | Path | Purpose | +|---|---|---| +| GET | `/health` | Local availability | +| GET | `/v1/me` | Opaque authenticated account ID | +| GET | `/v1/games/{title}/saves` | Current named save slots | +| GET | `/v1/games/{title}/saves/{slot}/revisions` | Latest 100 retained revisions | +| GET | `/v1/games/{title}/saves/{slot}/snapshot?revision=...` | Private current or historical package | +| PUT | `/v1/games/{title}/saves/{slot}/snapshot` | Conditional upload; ETag is the resulting revision | +| POST | `/v1/shares` | Share `{title_id, slot, revision, expires_in_seconds}` | +| POST | `/v1/shares/{token}/copy` | Copy to the recipient's `{title_id, slot}`, with a write precondition | +| DELETE | `/v1/shares/{share_id}` | Revoke your link | + +Slots use 1–40 lowercase letters, digits and hyphens. Title IDs are 16 uppercase +hex digits. Snapshot payloads use `application/vnd.nxnative.save+zip`; JSON bodies +are at most 16 KiB. Share lifetime is one minute to seven days. Archive size and +expanded contents are capped at 64 MiB, 4,096 entries; retained payload quota is +512 MiB per user. Those are prototype limits, not compatibility guarantees for +larger saves. There is no automatic pruning or unconditional overwrite endpoint. + +## Portable nxsave v1 package + +A ZIP contains `manifest.json` plus precisely its listed account/device entries. +Paths are relative, unique, bounded and free of traversal, symlinks or special +files; case/Unicode-colliding physical paths are rejected. File bytes are checked +against sizes, ZIP CRCs and SHA-256. Every uploaded snapshot must match its API +Title ID. Game packages, console keys and IPA/signing credentials are refused. + +```json +{ + "schema": 1, + "title_id": "010093801237C000", + "game_version": "2.1.0", + "build_id": "64 hex digits from the exact game's main NSO build ID", + "entries": [ + {"kind": "file", "path": "account/progress.bin", "size": 12, "sha256": "64 hex digits of the file"}, + {"kind": "directory", "path": "device/system"}, + {"kind": "file", "path": "account/.dm-case-1", "size": 5, "sha256": "64 hex digits", "guest_name": "CaseSensitiveName"} + ] +} +``` + +This illustrates the format; hashes/build ID must be real values in an actual +package. `guest_name` represents only the engine's known case-alias xattr, never +arbitrary extended attributes. Control presets remain local per-game preferences; +they are separate from game progress and are not uploaded by this prototype. + +## Validation + +Run `python -m unittest scripts.tests.test_save_api` using the prepared server +venv. Fixtures test real loopback HTTP, user isolation, conditional/concurrent +updates, immutable sharing, revocation/expiry, archive rejection and actual RSA +JWT signature/issuer/audience/expiry checks. No account, real save or game file is +used. This does not validate a deployed service or a native iPad-to-iPhone restore. diff --git a/dm/cloud/requirements.txt b/dm/cloud/requirements.txt new file mode 100644 index 0000000..b19f611 --- /dev/null +++ b/dm/cloud/requirements.txt @@ -0,0 +1 @@ +PyJWT[crypto]==2.15.1 diff --git a/dm/cloud/save_api.py b/dm/cloud/save_api.py new file mode 100644 index 0000000..c484cb8 --- /dev/null +++ b/dm/cloud/save_api.py @@ -0,0 +1,237 @@ +"""Portable, authenticated save-snapshot API. It never reads local game folders or restores a running game.""" +from contextlib import closing +import hashlib +import io +import json +import os +from pathlib import Path, PurePosixPath +import re +import secrets +import sqlite3 +import stat +import time +import unicodedata +import zipfile + +MAX_ARCHIVE=64*1024*1024 +MAX_ACCOUNT=512*1024*1024 +TITLE=re.compile(r'[0-9A-F]{16}') +SLOT=re.compile(r'[a-z0-9][a-z0-9-]{0,39}') + +class APIError(Exception): + def __init__(self,status,message): + super().__init__(message);self.status=status + + +def save_manifest(payload): + """Validate the portable nxsave v1 envelope before accepting immutable bytes.""" + if len(payload)>MAX_ARCHIVE:raise APIError(413,'Snapshot exceeds 64 MiB.') + try: + with zipfile.ZipFile(io.BytesIO(payload)) as archive: + entries=archive.infolist();names=[entry.filename for entry in entries] + if len(entries)>4097 or len(names)!=len(set(names)):raise ValueError('Duplicate or excessive ZIP entries.') + manifest=archive.getinfo('manifest.json') + if manifest.file_size>1024*1024:raise ValueError('Manifest is too large.') + metadata=json.loads(archive.read(manifest)) + if type(metadata.get('schema')) is not int or metadata['schema']!=1 or not TITLE.fullmatch(str(metadata.get('title_id',''))):raise ValueError('Invalid schema or title ID.') + if not re.fullmatch('[0-9a-fA-F]{64}',str(metadata.get('build_id',''))):raise ValueError('Missing exact game build ID.') + version=metadata.get('game_version') + if not isinstance(version,str) or not 1<=len(version)<=64:raise ValueError('Missing game version.') + records=metadata.get('entries') + if not isinstance(records,list) or not 1<=len(records)<=4096:raise ValueError('Missing save entries.') + for entry in entries: + mode=entry.external_attr>>16 + if entry.flag_bits&1 or stat.S_ISLNK(mode) or (stat.S_IFMT(mode) and not (stat.S_ISREG(mode) or stat.S_ISDIR(mode))): + raise ValueError('Links, encrypted entries and special files are not supported.') + expected={'manifest.json'};expanded=manifest.file_size;physical={} + for record in records: + name=record['path'];path=PurePosixPath(name) + if not isinstance(name,str) or name.endswith('/') or name!=str(path) or len(name.encode())>1024: + raise ValueError('Invalid snapshot path.') + if path.is_absolute() or '..' in path.parts or '\\' in name or ':' in name or any(ord(c)<32 for c in name):raise ValueError('Unsafe snapshot path.') + if path.parts[0] not in ('account','device') or len(path.parts)<2:raise ValueError('Only committed account/device save entries are allowed.') + if path.name.lower() in ('prod.keys','title.keys') or path.suffix.lower() in ('.nsp','.xci','.nsz','.xcz','.ipa','.p12','.mobileprovision'): + raise ValueError('Game packages and credentials are not save snapshots.') + if record.get('kind') not in ('file','directory'):raise ValueError('Invalid entry kind.') + key=unicodedata.normalize('NFC',name).casefold() + if key in physical:raise ValueError('Colliding physical paths; case aliases must use their distinct physical names.') + physical[key]=record['kind'] + zip_name=name+'/' if record['kind']=='directory' else name + if zip_name in expected:raise ValueError('Duplicate manifest entry.') + expected.add(zip_name);entry=archive.getinfo(zip_name) + mode=entry.external_attr>>16 + if entry.flag_bits&1 or stat.S_ISLNK(mode) or (stat.S_IFMT(mode) and not (stat.S_ISREG(mode) or stat.S_ISDIR(mode))): + raise ValueError('Encrypted entries, links and special files are not supported.') + if record['kind']=='directory': + if not entry.is_dir() or entry.file_size:raise ValueError('Invalid directory entry.') + else: + if type(record.get('size')) is not int or record['size']!=entry.file_size:raise ValueError('Snapshot size mismatch.') + expanded+=entry.file_size + if expanded>MAX_ARCHIVE:raise APIError(413,'Expanded snapshot exceeds 64 MiB.') + digest=hashlib.sha256() + with archive.open(entry) as stream: + for block in iter(lambda:stream.read(65536),b''):digest.update(block) + if digest.hexdigest()!=record.get('sha256'):raise ValueError('Snapshot hash mismatch.') + if 'guest_name' in record: + guest=record['guest_name'] + if not path.name.startswith('.dm-case-') or not isinstance(guest,str) or not guest or len(guest.encode())>768 or guest in ('.','..') or any(c in guest for c in '/\\\0'): + raise ValueError('Invalid case-alias metadata.') + for path in physical: + if any(physical.get(str(parent))=='file' for parent in PurePosixPath(path).parents):raise ValueError('A file cannot contain another save entry.') + if set(names)!=expected:raise ValueError('ZIP entries do not match the manifest.') + return metadata + except APIError:raise + except (ValueError,KeyError,TypeError,AttributeError,UnicodeError,zipfile.BadZipFile,RuntimeError) as error: + raise APIError(400,'Invalid nxsave snapshot: '+str(error)) from error + + +class SaveStore: + def __init__(self,path): + self.path=Path(path);self.path.parent.mkdir(parents=True,exist_ok=True) + with closing(self.connect()) as db: + db.executescript(''' + PRAGMA journal_mode=WAL; + CREATE TABLE IF NOT EXISTS revisions(id TEXT PRIMARY KEY, owner TEXT NOT NULL, title TEXT NOT NULL, + slot TEXT NOT NULL, created REAL NOT NULL, metadata TEXT NOT NULL, payload BLOB NOT NULL); + CREATE TABLE IF NOT EXISTS heads(owner TEXT, title TEXT, slot TEXT, revision TEXT NOT NULL, + PRIMARY KEY(owner,title,slot)); + CREATE TABLE IF NOT EXISTS shares(id TEXT PRIMARY KEY, token_hash TEXT UNIQUE NOT NULL, owner TEXT NOT NULL, + revision TEXT NOT NULL, expires REAL NOT NULL, revoked INTEGER NOT NULL DEFAULT 0); + ''') + os.chmod(self.path,0o600) + def connect(self): + db=sqlite3.connect(self.path,isolation_level=None,timeout=10);db.row_factory=sqlite3.Row + db.execute('PRAGMA synchronous=FULL');return db + @staticmethod + def identity(title,slot): + if not TITLE.fullmatch(title) or not SLOT.fullmatch(slot):raise APIError(400,'Invalid title ID or slot.') + def list(self,owner,title): + self.identity(title,'default') + with closing(self.connect()) as db: + rows=db.execute('SELECT r.id,r.title,r.slot,r.created,r.metadata FROM heads h JOIN revisions r ON r.id=h.revision WHERE h.owner=? AND h.title=? ORDER BY r.slot',(owner,title)).fetchall() + return [dict(revision=r['id'],slot=r['slot'],created=r['created'],game=json.loads(r['metadata'])) for r in rows] + def get(self,owner,title,slot,revision=None): + self.identity(title,slot) + with closing(self.connect()) as db: + if revision:row=db.execute('SELECT * FROM revisions WHERE owner=? AND title=? AND slot=? AND id=?',(owner,title,slot,revision)).fetchone() + else:row=db.execute('SELECT r.* FROM heads h JOIN revisions r ON r.id=h.revision WHERE h.owner=? AND h.title=? AND h.slot=?',(owner,title,slot)).fetchone() + if row is None:raise APIError(404,'Snapshot not found.') + return row + def history(self,owner,title,slot): + self.identity(title,slot) + with closing(self.connect()) as db: + rows=db.execute('SELECT id,created,metadata FROM revisions WHERE owner=? AND title=? AND slot=? ORDER BY created DESC,id DESC LIMIT 100',(owner,title,slot)).fetchall() + return [dict(revision=r['id'],created=r['created'],game=json.loads(r['metadata'])) for r in rows] + def put(self,owner,title,slot,payload,expected): + self.identity(title,slot) + if expected is None:raise APIError(428,'If-Match is required; use If-None-Match: * for a new slot.') + metadata=save_manifest(payload) + if metadata['title_id']!=title:raise APIError(400,'Snapshot title does not match the requested game.') + with closing(self.connect()) as db: + db.execute('BEGIN IMMEDIATE') + try: + old=db.execute('SELECT revision FROM heads WHERE owner=? AND title=? AND slot=?',(owner,title,slot)).fetchone() + current=old['revision'] if old else '' + if current!=expected:raise APIError(412,'Cloud save changed. Keep both versions; download the current revision before choosing.') + used=db.execute('SELECT COALESCE(SUM(length(payload)),0) FROM revisions WHERE owner=?',(owner,)).fetchone()[0] + if used+len(payload)>MAX_ACCOUNT:raise APIError(413,'Account snapshot quota reached; existing versions were preserved.') + revision=secrets.token_hex(16) + summary={k:metadata[k] for k in ('title_id','build_id','game_version')} + db.execute('INSERT INTO revisions VALUES(?,?,?,?,?,?,?)',(revision,owner,title,slot,time.time(),json.dumps(summary),payload)) + db.execute('INSERT INTO heads VALUES(?,?,?,?) ON CONFLICT(owner,title,slot) DO UPDATE SET revision=excluded.revision',(owner,title,slot,revision)) + db.commit();return revision + except BaseException:db.rollback();raise + def share(self,owner,title,slot,revision,ttl): + row=self.get(owner,title,slot,revision) + if type(ttl) is not int or not 60<=ttl<=7*24*3600:raise APIError(400,'Share expiry must be between one minute and seven days.') + token=secrets.token_urlsafe(32);identifier=secrets.token_hex(16) + with closing(self.connect()) as db: + db.execute('INSERT INTO shares VALUES(?,?,?,?,?,0)',(identifier,hashlib.sha256(token.encode()).hexdigest(),owner,row['id'],time.time()+ttl)) + return dict(id=identifier,token=token,expires_in_seconds=ttl) + def revoke(self,owner,identifier): + with closing(self.connect()) as db: + result=db.execute('UPDATE shares SET revoked=1 WHERE owner=? AND id=?',(owner,identifier)) + if not result.rowcount:raise APIError(404,'Share not found.') + def copy_share(self,recipient,token,title,slot,expected): + with closing(self.connect()) as db: + row=db.execute('SELECT r.* FROM shares s JOIN revisions r ON r.id=s.revision WHERE s.token_hash=? AND s.revoked=0 AND s.expires>?', + (hashlib.sha256(token.encode()).hexdigest(),time.time())).fetchone() + if row is None:raise APIError(404,'Share is unavailable or expired.') + if row['title']!=title:raise APIError(400,'Share belongs to another game.') + # Shares reference an immutable revision, never the sender's changing head; copy is into the recipient's own slot. + return self.put(recipient,title,slot,row['payload'],expected) + + +def oidc_verifier(issuer,audience,jwks_url): + from urllib.parse import urlparse + import jwt + if not issuer or not audience or not jwks_url:raise ValueError('Configure OIDC_ISSUER, OIDC_AUDIENCE and OIDC_JWKS_URL first.') + issuer_url=urlparse(issuer);key_url=urlparse(jwks_url) + if issuer_url.scheme!='https' or key_url.scheme!='https' or issuer_url.hostname!=key_url.hostname: + raise ValueError('OIDC issuer and JWKS must use HTTPS on the same trusted host.') + client=jwt.PyJWKClient(jwks_url,cache_keys=True,timeout=10) + def verify(token): + try: + if jwt.get_unverified_header(token).get('alg') not in ('RS256','ES256'):raise jwt.InvalidAlgorithmError('Unsupported signing algorithm.') + key=client.get_signing_key_from_jwt(token) + claims=jwt.decode(token,key.key,algorithms=['RS256','ES256'],issuer=issuer,audience=audience,leeway=30, + options={'require':['exp','iat','iss','sub','aud']}) + if not isinstance(claims['sub'],str) or not claims['sub']:raise ValueError('Missing subject.') + return hashlib.sha256((issuer+'\0'+claims['sub']).encode()).hexdigest() + except jwt.PyJWKClientConnectionError as error:raise APIError(503,'Identity provider unavailable; retry later.') from error + except (jwt.PyJWTError,ValueError) as error:raise APIError(401,'Sign in again.') from error + return verify + + +class SaveAPI: + def __init__(self,store,verify_token):self.store=store;self.verify_token=verify_token + def __call__(self,environ,start_response): + from http import HTTPStatus + from urllib.parse import parse_qs + headers=[] + try: + method=environ['REQUEST_METHOD'];path=environ.get('PATH_INFO','') + if method=='GET' and path=='/health':data=b'{"status":"ok"}';status=200 + else: + auth=environ.get('HTTP_AUTHORIZATION','') + if not auth.startswith('Bearer ') or len(auth)>16384:raise APIError(401,'Sign in is required.') + owner=self.verify_token(auth[7:]) + if not isinstance(owner,str) or not owner:raise APIError(401,'Invalid identity.') + length=environ.get('CONTENT_LENGTH','') + try:length=int(length or 0) + except ValueError as error:raise APIError(400,'Invalid Content-Length.') from error + if length<0 or length>MAX_ARCHIVE:raise APIError(413,'Request exceeds snapshot limit.') + if method!='PUT' and length>16384:raise APIError(413,'JSON request is too large.') + body=environ['wsgi.input'].read(length) + if len(body)!=length:raise APIError(400,'Incomplete request.') + if method in ('PUT','POST') and (environ.get('HTTP_IF_MATCH') and environ.get('HTTP_IF_NONE_MATCH')):raise APIError(400,'Choose one write precondition.') + expected=environ.get('HTTP_IF_MATCH') + if expected is not None:expected=expected.strip('"') + elif environ.get('HTTP_IF_NONE_MATCH')=='*':expected='' + game=re.fullmatch(r'/v1/games/([0-9A-F]{16})/saves(?:/([a-z0-9-]+)(/snapshot)?)?',path) + history=re.fullmatch(r'/v1/games/([0-9A-F]{16})/saves/([a-z0-9-]+)/revisions',path) + share_copy=re.fullmatch(r'/v1/shares/([A-Za-z0-9_-]{20,100})/copy',path) + revoke=re.fullmatch(r'/v1/shares/([a-f0-9]{32})',path) + result={};status=200 + if method=='GET' and path=='/v1/me':result={'account':owner} + elif method=='GET' and history:result={'revisions':self.store.history(owner,history[1],history[2])} + elif game and method=='GET' and game[2] is None:result={'saves':self.store.list(owner,game[1])} + elif game and game[3] and method=='GET': + revision=parse_qs(environ.get('QUERY_STRING','')).get('revision',[None])[0] + row=self.store.get(owner,game[1],game[2],revision) + headers=[('Content-Type','application/vnd.nxnative.save+zip'),('ETag','"'+row['id']+'"')] + data=row['payload'];start_response('200 OK',headers+[('Content-Length',str(len(data))),('Cache-Control','no-store')]);return [data] + elif game and game[3] and method=='PUT': + revision=self.store.put(owner,game[1],game[2],body,expected);status=201;result={'revision':revision};headers=[('ETag','"'+revision+'"')] + elif method=='POST' and path=='/v1/shares': + value=json.loads(body);result=self.store.share(owner,value['title_id'],value['slot'],value['revision'],value.get('expires_in_seconds',3600));status=201 + elif method=='POST' and share_copy: + value=json.loads(body);revision=self.store.copy_share(owner,share_copy[1],value['title_id'],value['slot'],expected);result={'revision':revision};status=201 + elif method=='DELETE' and revoke:self.store.revoke(owner,revoke[1]);result={'revoked':True} + else:raise APIError(404,'Endpoint not found.') + data=json.dumps(result).encode() + except APIError as error:status=error.status;data=json.dumps({'error':str(error)}).encode() + except (ValueError,KeyError,TypeError,AttributeError):status=400;data=json.dumps({'error':'Invalid request.'}).encode() + except Exception:status=500;data=b'{"error":"Server error; existing snapshots were preserved."}' + start_response(f'{status} {HTTPStatus(status).phrase}',headers+[('Content-Type','application/json'),('Content-Length',str(len(data))),('Cache-Control','no-store')]) + return [data] diff --git a/dm/ios/Sources/DmGamepad.m b/dm/ios/Sources/DmGamepad.m index 6316a74..722721f 100644 --- a/dm/ios/Sources/DmGamepad.m +++ b/dm/ios/Sources/DmGamepad.m @@ -19,6 +19,7 @@ #include #include "dm.h" #import "DmPadGeometry.h" +#import "DmPadProfiles.h" #include "dm_input.h" #include "front.h" #include "game_profile.h" @@ -114,11 +115,12 @@ static __weak DmGamepad *g_view; CALayer *_controls; // holds every control layer: hidden while a physical controller is connected (the view itself stays, for game touches) BOOL _padOff; // a physical controller is connected: every touch goes to the game NSMutableDictionary *_custom; + DmPadProfiles *_profiles; BOOL _editing, _editWasTap; int _selected; UITouch *_dragTouch; CGPoint _dragStart, _dragHome; CGRect _lastSafe; - UIButton *_editButton, *_chooseButton; + UIButton *_editButton, *_chooseButton, *_profileButton, *_saveAsButton; UIView *_tools; UISlider *_sizeSlider, *_alphaSlider; UILabel *_editInfo; @@ -169,6 +171,7 @@ static BOOL fullOnly(int b) { return b == B_LS || b == B_RS || b >= B_LEFT; } int l = saved ? (int)saved.integerValue : DM_GAME_PAD_LAYOUT; if (l == 2 && dm_opmode() != 0) l = 0; // Tap only in handheld mode (env.txt's DM_OPMODE may have changed it) _full = l == 1; _tap = l == 2; + _profiles=[[DmPadProfiles alloc] initWithDefaults:NSUserDefaults.standardUserDefaults game:@DM_GAME_ID]; [self loadControls]; [self setupEditor]; [self applyLayout]; @@ -396,7 +399,7 @@ static void placeLabel(CATextLayer *t, CGPoint c, CGFloat fs) { CGRect safe = self.safe; if (!CGRectEqualToRect(safe, _lastSafe)) { _lastSafe = safe; [self reset]; } _editButton.frame = CGRectMake(CGRectGetMidX(safe)-65, CGRectGetMinY(safe)+46, 130, 32); - _tools.frame = CGRectMake(CGRectGetMidX(safe)-160, CGRectGetMidY(safe)-90, 320, 180); + _tools.frame = CGRectMake(CGRectGetMidX(safe)-160, CGRectGetMidY(safe)-112, 320, 224); [CATransaction begin]; [CATransaction setDisableActions:YES]; _controls.frame = self.layer.bounds; for (int b = B_A; b <= B_Y; b++) { CGPoint c = [self facePos:b]; setPath(_key[b], circlePath(c, [self faceRadius:b])); placeLabel(_label[b], c, 20 * u * [self scale:b]); } @@ -438,19 +441,11 @@ static void placeLabel(CATextLayer *t, CGPoint c, CGFloat fs) { // Versioned per-app preferences: invalid controls fall back independently. -- (NSString *)controlsKey { return _full ? @"DmPadControls.v1.3D" : @"DmPadControls.v1.2D"; } +- (NSString *)controlsLayout { return _full ? @"3D" : @"2D"; } - (NSString *)controlID:(int)b { return @[@"A",@"B",@"X",@"Y",@"L3",@"R3",@"L",@"R",@"ZL",@"ZR",@"Plus",@"Minus",@"unused12",@"unused13",@"unused14",@"unused15",@"Stick",@"Dpad"][b]; } -- (void)loadControls { - _custom = [NSMutableDictionary dictionary]; - id saved = [NSUserDefaults.standardUserDefaults objectForKey:self.controlsKey]; - if (![saved isKindOfClass:NSDictionary.class]) return; - for (int b = 0; b < 18; b++) { - id v = saved[[self controlID:b]]; - if (dm_pad_entry_valid(v)) _custom[[self controlID:b]] = [v copy]; - } -} +- (void)loadControls { _custom=[[_profiles controls:self.controlsLayout slot:_profiles.selected] mutableCopy]; } - (CGFloat)scale:(int)b { NSNumber *n = _custom[[self controlID:b]][@"scale"]; return n ? n.doubleValue : 1; } - (float)opacity:(int)b { NSNumber *n = _custom[[self controlID:b]][@"alpha"]; float a = n ? n.floatValue : 1; return _editing ? fmaxf(a, 0.35) : a; } - (CGPoint)position:(int)b base:(CGPoint)p { @@ -483,18 +478,22 @@ static void placeLabel(CATextLayer *t, CGPoint c, CGFloat fs) { - (void)setupEditor { _editButton = [self editorButton:@"Edit controls" action:@selector(toggleEditor) frame:CGRectZero parent:self]; _tools = [UIView new]; _tools.backgroundColor = [UIColor colorWithWhite:0 alpha:0.9]; _tools.layer.cornerRadius = 10; [self addSubview:_tools]; _tools.hidden = YES; - [self editorButton:@"Save" action:@selector(saveEdit) frame:CGRectMake(8,8,90,32) parent:_tools]; - [self editorButton:@"Cancel" action:@selector(cancelEdit) frame:CGRectMake(110,8,90,32) parent:_tools]; - [self editorButton:@"Reset all" action:@selector(resetAllControls) frame:CGRectMake(212,8,100,32) parent:_tools]; - _chooseButton = [self editorButton:@"A ▾" action:NULL frame:CGRectMake(8,46,145,30) parent:_tools]; - [self editorButton:@"Reset control" action:@selector(resetControl) frame:CGRectMake(163,46,149,30) parent:_tools]; - _sizeSlider = [[UISlider alloc] initWithFrame:CGRectMake(78,80,230,30)]; _sizeSlider.minimumValue = 0.5; _sizeSlider.maximumValue = 2; - _alphaSlider = [[UISlider alloc] initWithFrame:CGRectMake(78,112,230,30)]; _alphaSlider.maximumValue = 1; + _profileButton=[self editorButton:@"Profile" action:NULL frame:CGRectMake(8,8,192,32) parent:_tools]; + _profileButton.titleLabel.adjustsFontSizeToFitWidth=YES;_profileButton.titleLabel.minimumScaleFactor=0.75; + _saveAsButton=[self editorButton:@"Save as…" action:NULL frame:CGRectMake(208,8,104,32) parent:_tools]; + [self editorButton:@"Save" action:@selector(saveEdit) frame:CGRectMake(8,48,90,32) parent:_tools]; + [self editorButton:@"Cancel" action:@selector(cancelEdit) frame:CGRectMake(110,48,90,32) parent:_tools]; + [self editorButton:@"Reset all" action:@selector(resetAllControls) frame:CGRectMake(212,48,100,32) parent:_tools]; + _chooseButton = [self editorButton:@"A ▾" action:NULL frame:CGRectMake(8,86,145,30) parent:_tools]; + [self editorButton:@"Reset control" action:@selector(resetControl) frame:CGRectMake(163,86,149,30) parent:_tools]; + _sizeSlider = [[UISlider alloc] initWithFrame:CGRectMake(78,120,230,30)]; _sizeSlider.minimumValue = 0.5; _sizeSlider.maximumValue = 2; + _alphaSlider = [[UISlider alloc] initWithFrame:CGRectMake(78,152,230,30)]; _alphaSlider.maximumValue = 1; for (UISlider *slider in @[_sizeSlider,_alphaSlider]) { [slider addTarget:self action:@selector(adjustControl) forControlEvents:UIControlEventValueChanged]; [_tools addSubview:slider]; } - for (int i=0;i<2;i++) { UILabel *l=[[UILabel alloc] initWithFrame:CGRectMake(8,80+32*i,70,30)]; l.text=i?@"Opacity":@"Size"; l.textColor=UIColor.whiteColor; l.font=[UIFont systemFontOfSize:13]; [_tools addSubview:l]; } - _editInfo = [[UILabel alloc] initWithFrame:CGRectMake(8,144,304,32)]; _editInfo.numberOfLines=2; _editInfo.font=[UIFont systemFontOfSize:11]; _editInfo.textColor=UIColor.whiteColor; [_tools addSubview:_editInfo]; + for (int i=0;i<2;i++) { UILabel *l=[[UILabel alloc] initWithFrame:CGRectMake(8,120+32*i,70,30)]; l.text=i?@"Opacity":@"Size"; l.textColor=UIColor.whiteColor; l.font=[UIFont systemFontOfSize:13]; [_tools addSubview:l]; } + _editInfo = [[UILabel alloc] initWithFrame:CGRectMake(8,184,304,32)]; _editInfo.numberOfLines=2; _editInfo.font=[UIFont systemFontOfSize:11]; _editInfo.textColor=UIColor.whiteColor; [_tools addSubview:_editInfo]; } - (void)updateEditor { + [self updateProfileMenu]; [_chooseButton setTitle:[NSString stringWithFormat:@"%@ ▾",[self controlID:_selected]] forState:UIControlStateNormal]; _sizeSlider.value = [self scale:_selected]; NSNumber *a = _custom[[self controlID:_selected]][@"alpha"]; _alphaSlider.value = a ? a.floatValue : 1; _editInfo.text = [NSString stringWithFormat:@"Size %.0f%% · Opacity %.0f%% · Game is running\nDrag outside tools. 0%% still receives touches.",_sizeSlider.value*100,_alphaSlider.value*100]; @@ -505,6 +504,58 @@ static void placeLabel(CATextLayer *t, CGPoint c, CGFloat fs) { _chooseButton.menu = [UIMenu menuWithTitle:@"Select control" children:actions]; _chooseButton.showsMenuAsPrimaryAction=YES; [self setNeedsLayout]; } +- (UIViewController *)editorPresenter { + UIResponder *r=self; + while (r && ![r isKindOfClass:UIViewController.class]) r=r.nextResponder; + UIViewController *parent=(UIViewController *)r; + while (parent.presentedViewController) parent=parent.presentedViewController; + return parent; +} +- (void)confirm:(NSString *)message actionTitle:(NSString *)title action:(void (^)(void))done { + UIAlertController *alert=[UIAlertController alertControllerWithTitle:message message:nil preferredStyle:UIAlertControllerStyleAlert]; + [alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]]; + [alert addAction:[UIAlertAction actionWithTitle:title style:UIAlertActionStyleDefault handler:^(__unused UIAlertAction *a) { done(); }]]; + [self.editorPresenter presentViewController:alert animated:YES completion:nil]; +} +- (void)selectProfile:(NSInteger)slot { + if (slot==_profiles.selected) return; + void (^change)(void)=^{ [self reset]; self->_dragTouch=nil; [self->_profiles select:slot]; [self loadControls]; [self updateEditor]; }; + if (![_custom isEqual:[_profiles controls:self.controlsLayout slot:_profiles.selected]]) + [self confirm:@"Discard unsaved changes?" actionTitle:@"Switch profile" action:change]; + else change(); +} +- (void)renameProfile { + UIAlertController *alert=[UIAlertController alertControllerWithTitle:@"Name this profile" message:@"For example: iPhone, iPad or Large buttons (up to 32 characters)." preferredStyle:UIAlertControllerStyleAlert]; + [alert addTextFieldWithConfigurationHandler:^(UITextField *field) { field.text=[self->_profiles name:self->_profiles.selected]; field.clearButtonMode=UITextFieldViewModeWhileEditing; }]; + [alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]]; + __weak UIAlertController *weakAlert=alert; + [alert addAction:[UIAlertAction actionWithTitle:@"Rename" style:UIAlertActionStyleDefault handler:^(__unused UIAlertAction *a) { + if (![self->_profiles rename:self->_profiles.selected name:weakAlert.textFields.firstObject.text ?: @""]) + UIAccessibilityPostNotification(UIAccessibilityAnnouncementNotification,@"Use a name between 1 and 32 characters."); + [self updateEditor]; + }]]; + [self.editorPresenter presentViewController:alert animated:YES completion:nil]; +} +- (void)updateProfileMenu { + [_profileButton setTitle:[NSString stringWithFormat:@"%@ ▾",[_profiles name:_profiles.selected]] forState:UIControlStateNormal]; + NSMutableArray *profiles=[NSMutableArray array], *copies=[NSMutableArray array]; + for (NSInteger slot=0;slot<4;slot++) { + NSInteger target=slot; + UIAction *pick=[UIAction actionWithTitle:[_profiles name:slot] image:nil identifier:nil handler:^(__unused UIAction *a) { [self selectProfile:target]; }]; + pick.state=slot==_profiles.selected ? UIMenuElementStateOn : UIMenuElementStateOff;[profiles addObject:pick]; + if (slot!=_profiles.selected) [copies addObject:[UIAction actionWithTitle:[_profiles name:slot] image:nil identifier:nil handler:^(__unused UIAction *a) { + NSString *message=[NSString stringWithFormat:@"Replace %@ with this profile?",[self->_profiles name:target]]; + [self confirm:message actionTitle:@"Save copy" action:^{ + [self reset];self->_dragTouch=nil; + [self->_profiles copySlot:self->_profiles.selected to:target draft:self->_custom layout:self.controlsLayout]; + [self loadControls];[self updateEditor]; + }]; + }]]; + } + [profiles addObject:[UIMenu menuWithTitle:@"" image:nil identifier:nil options:UIMenuOptionsDisplayInline children:@[[UIAction actionWithTitle:@"Rename…" image:nil identifier:nil handler:^(__unused UIAction *a) { [self renameProfile]; }]]]]; + _profileButton.menu=[UIMenu menuWithTitle:@"Control profiles" children:profiles];_profileButton.showsMenuAsPrimaryAction=YES; + _saveAsButton.menu=[UIMenu menuWithTitle:@"Save a copy to" children:copies];_saveAsButton.showsMenuAsPrimaryAction=YES; +} - (void)toggleEditor { if (_editing) { _tools.hidden = !_tools.hidden; return; } _editWasTap = _tap; @@ -513,7 +564,7 @@ static void placeLabel(CATextLayer *t, CGPoint c, CGFloat fs) { [_editButton setTitle:@"Show / hide tools" forState:UIControlStateNormal]; [self updateEditor]; } - (void)finishEdit { [self reset]; _editing=NO; if (_editWasTap) { _tap=YES; [self applyLayout]; } _tools.hidden=YES; _controls.hidden=_padOff; [_editButton setTitle:@"Edit controls" forState:UIControlStateNormal]; [self setNeedsLayout]; } -- (void)saveEdit { [NSUserDefaults.standardUserDefaults setObject:[_custom copy] forKey:self.controlsKey]; [self finishEdit]; } +- (void)saveEdit { [_profiles save:_custom layout:self.controlsLayout slot:_profiles.selected]; [self finishEdit]; } - (void)cancelEdit { [self loadControls]; [self finishEdit]; } - (void)resetAllControls { [_custom removeAllObjects]; [self updateEditor]; } - (void)resetControl { [_custom removeObjectForKey:[self controlID:_selected]]; [self updateEditor]; } diff --git a/dm/ios/Sources/DmPadProfiles.h b/dm/ios/Sources/DmPadProfiles.h new file mode 100644 index 0000000..51273bc --- /dev/null +++ b/dm/ios/Sources/DmPadProfiles.h @@ -0,0 +1,83 @@ +// Four named touch-control presets per game. Foundation-only so native tests use the runtime storage. +#import +#import "DmPadGeometry.h" + +static inline NSArray *dm_pad_control_ids(void) { + return @[@"A",@"B",@"X",@"Y",@"L3",@"R3",@"L",@"R",@"ZL",@"ZR",@"Plus",@"Minus",@"Stick",@"Dpad"]; +} +static inline NSDictionary *dm_pad_clean_controls(id source) { + if (![source isKindOfClass:NSDictionary.class]) return @{}; + NSMutableDictionary *out=[NSMutableDictionary dictionary]; + for (NSString *key in dm_pad_control_ids()) if (dm_pad_entry_valid(source[key])) out[key]=[source[key] copy]; + return [out copy]; +} + +@interface DmPadProfiles : NSObject +@property(nonatomic,readonly) NSInteger selected; +- (instancetype)initWithDefaults:(NSUserDefaults *)defaults game:(NSString *)game; +- (NSString *)name:(NSInteger)slot; +- (NSDictionary *)controls:(NSString *)layout slot:(NSInteger)slot; +- (void)select:(NSInteger)slot; +- (BOOL)rename:(NSInteger)slot name:(NSString *)name; +- (void)save:(NSDictionary *)controls layout:(NSString *)layout slot:(NSInteger)slot; +- (void)copySlot:(NSInteger)source to:(NSInteger)target draft:(NSDictionary *)controls layout:(NSString *)layout; +@end + +@implementation DmPadProfiles { + NSUserDefaults *_defaults; + NSString *_key; + NSMutableArray *_slots; + NSInteger _selected; +} +- (instancetype)initWithDefaults:(NSUserDefaults *)defaults game:(NSString *)game { + if ((self=[super init])) { + _defaults=defaults;_key=[@"DmPadProfiles.v2." stringByAppendingString:game]; + _slots=[NSMutableArray array]; + id raw=[defaults objectForKey:_key]; + NSDictionary *saved=[raw isKindOfClass:NSDictionary.class] && [raw[@"schema"] isEqual:@2] ? raw : nil; + NSArray *slots=[saved[@"slots"] isKindOfClass:NSArray.class] ? saved[@"slots"] : @[]; + for (NSInteger i=0;i<4;i++) { + NSDictionary *slot=i<(NSInteger)slots.count && [slots[i] isKindOfClass:NSDictionary.class] ? slots[i] : @{}; + NSString *name=[slot[@"name"] isKindOfClass:NSString.class] ? slot[@"name"] : @""; + name=[name stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; + if (!name.length || name.length>32) name=[NSString stringWithFormat:@"Profile %ld",(long)i+1]; + NSDictionary *layouts=[slot[@"layouts"] isKindOfClass:NSDictionary.class] ? slot[@"layouts"] : @{}; + [_slots addObject:[@{@"name":name,@"layouts":[@{@"2D":dm_pad_clean_controls(layouts[@"2D"]),@"3D":dm_pad_clean_controls(layouts[@"3D"])} mutableCopy]} mutableCopy]]; + } + NSNumber *selected=[saved[@"selected"] isKindOfClass:NSNumber.class] ? saved[@"selected"] : @0; + _selected=selected.integerValue>=0 && selected.integerValue<4 ? selected.integerValue : 0; + if (!saved) { + // Copy both legacy layouts once; never remove their original preference keys. + _slots[0][@"layouts"]=[@{@"2D":dm_pad_clean_controls([defaults objectForKey:@"DmPadControls.v1.2D"]), + @"3D":dm_pad_clean_controls([defaults objectForKey:@"DmPadControls.v1.3D"])} mutableCopy]; + [self persist]; + } + } + return self; +} +- (BOOL)validSlot:(NSInteger)slot { return slot>=0 && slot<4; } +- (BOOL)validLayout:(NSString *)layout { return [@[@"2D",@"3D"] containsObject:layout]; } +- (void)persist { [_defaults setObject:@{@"schema":@2,@"selected":@(_selected),@"slots":_slots} forKey:_key]; } +- (NSInteger)selected { return _selected; } +- (NSString *)name:(NSInteger)slot { return [self validSlot:slot] ? _slots[slot][@"name"] : @""; } +- (NSDictionary *)controls:(NSString *)layout slot:(NSInteger)slot { + return [self validSlot:slot] && [self validLayout:layout] ? dm_pad_clean_controls(_slots[slot][@"layouts"][layout]) : @{}; +} +- (void)select:(NSInteger)slot { if ([self validSlot:slot]) { _selected=slot;[self persist]; } } +- (BOOL)rename:(NSInteger)slot name:(NSString *)name { + if (![self validSlot:slot]) return NO; + name=[name stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; + if (!name.length || name.length>32 || [name rangeOfCharacterFromSet:NSCharacterSet.controlCharacterSet].location!=NSNotFound) return NO; + _slots[slot][@"name"]=name;[self persist];return YES; +} +- (void)save:(NSDictionary *)controls layout:(NSString *)layout slot:(NSInteger)slot { + if (![self validSlot:slot] || ![self validLayout:layout]) return; + _slots[slot][@"layouts"][layout]=dm_pad_clean_controls(controls);[self persist]; +} +- (void)copySlot:(NSInteger)source to:(NSInteger)target draft:(NSDictionary *)controls layout:(NSString *)layout { + if (![self validSlot:source] || ![self validSlot:target] || ![self validLayout:layout] || source==target) return; + // Copy both layouts, replacing only the current layout with the visible draft. Keep the target's name. + NSMutableDictionary *layouts=[@{@"2D":[self controls:@"2D" slot:source],@"3D":[self controls:@"3D" slot:source]} mutableCopy]; + layouts[layout]=dm_pad_clean_controls(controls);_slots[target][@"layouts"]=layouts;_selected=target;[self persist]; +} +@end diff --git a/dm/ios/tests/pad_profiles_check.m b/dm/ios/tests/pad_profiles_check.m new file mode 100644 index 0000000..702f934 --- /dev/null +++ b/dm/ios/tests/pad_profiles_check.m @@ -0,0 +1,44 @@ +#import "../Sources/DmPadProfiles.h" +#include +int main(void) { @autoreleasepool { + NSString *suite=[@"nxnative.pad.profiles.tests." stringByAppendingString:NSUUID.UUID.UUIDString]; + NSUserDefaults *defaults=[[NSUserDefaults alloc] initWithSuiteName:suite]; + NSDictionary *button=@{@"x":@0.9,@"y":@0.2,@"scale":@1.5,@"alpha":@0.6}; + NSDictionary *legacy=@{@"A":button}; + [defaults setObject:legacy forKey:@"DmPadControls.v1.2D"]; + [defaults setObject:@{@"Stick":button} forKey:@"DmPadControls.v1.3D"]; + DmPadProfiles *profiles=[[DmPadProfiles alloc] initWithDefaults:defaults game:@"game-one"]; + assert(profiles.selected==0 && [[profiles controls:@"2D" slot:0] isEqual:legacy]); + assert([[profiles controls:@"3D" slot:0][@"Stick"] isEqual:button]); + assert([[defaults objectForKey:@"DmPadControls.v1.2D"] isEqual:legacy]); + assert([profiles controls:@"2D" slot:3].count==0); + assert([profiles rename:0 name:@" iPhone "] && [[profiles name:0] isEqual:@"iPhone"]); + assert(![profiles rename:0 name:@" "] && ![profiles rename:0 name:@"bad\nname"]); + assert(![profiles rename:0 name:[@"x" stringByPaddingToLength:33 withString:@"x" startingAtIndex:0]]); + assert(![profiles rename:4 name:@"out of bounds"]); + NSMutableDictionary *draft=[[profiles controls:@"2D" slot:0] mutableCopy]; + [draft removeAllObjects]; // Cancel is a reload: no draft writes until Save or Save as. + assert([[profiles controls:@"2D" slot:0] isEqual:legacy]); + [profiles rename:1 name:@"iPad"]; + [profiles copySlot:0 to:1 draft:@{@"B":button} layout:@"2D"]; + assert(profiles.selected==1 && [[profiles name:1] isEqual:@"iPad"]); + assert([[profiles controls:@"2D" slot:0] isEqual:legacy]); + assert([[profiles controls:@"2D" slot:1][@"B"] isEqual:button]); + assert([[profiles controls:@"3D" slot:1][@"Stick"] isEqual:button]); + [profiles save:@{} layout:@"3D" slot:1]; + assert([profiles controls:@"3D" slot:1].count==0 && [profiles controls:@"3D" slot:0].count==1); + [profiles select:3];[profiles save:@{@"Dpad":button} layout:@"3D" slot:3]; + DmPadProfiles *reopened=[[DmPadProfiles alloc] initWithDefaults:defaults game:@"game-one"]; + assert(reopened.selected==3 && [[reopened name:1] isEqual:@"iPad"]); + assert([[reopened controls:@"3D" slot:3][@"Dpad"] isEqual:button]); + DmPadProfiles *other=[[DmPadProfiles alloc] initWithDefaults:defaults game:@"game-two"]; + assert(other.selected==0 && [[other name:1] isEqual:@"Profile 2"] && [other controls:@"3D" slot:3].count==0); + // Malformed stored values must not become hit regions or crash the runtime. + [defaults setObject:@{@"schema":@2,@"selected":@99,@"slots":@[@{@"name":@42,@"layouts":@{@"2D":@{@"A":@{@"x":@YES},@"B":button,@"unknown":button}}},@"invalid slot"]} forKey:@"DmPadProfiles.v2.bad"]; + DmPadProfiles *bad=[[DmPadProfiles alloc] initWithDefaults:defaults game:@"bad"]; + assert(bad.selected==0 && [[bad name:0] isEqual:@"Profile 1"]); + assert([bad controls:@"2D" slot:0].count==1 && [bad controls:@"2D" slot:0][@"B"]); + [bad select:-1];[bad save:legacy layout:@"Tap" slot:0];assert(bad.selected==0); + [defaults removePersistentDomainForName:suite]; + puts("pad profiles: PASS (four slots, names, migration, copy, cancel, layouts, game isolation, relaunch and corrupt preferences)"); +} } diff --git a/dm/ios/tests/test_pad_profiles.py b/dm/ios/tests/test_pad_profiles.py new file mode 100644 index 0000000..e99b098 --- /dev/null +++ b/dm/ios/tests/test_pad_profiles.py @@ -0,0 +1,15 @@ +"""Exercise the actual Foundation profile storage used by the iOS controls editor.""" +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +@unittest.skipUnless(sys.platform=='darwin','requires Apple Foundation') +class PadProfiles(unittest.TestCase): + def test_runtime_storage(self): + with tempfile.TemporaryDirectory() as temporary: + exe=Path(temporary)/'profiles' + subprocess.run(['clang','-fobjc-arc','-Wall','-Wextra','-Werror',str(Path(__file__).with_name('pad_profiles_check.m')), + '-framework','Foundation','-framework','CoreGraphics','-o',str(exe)],check=True) + subprocess.run([str(exe)],check=True) diff --git a/docs/personal-ios.md b/docs/personal-ios.md index f807281..a433c55 100644 --- a/docs/personal-ios.md +++ b/docs/personal-ios.md @@ -124,8 +124,15 @@ its center; drag outside the tools to move it. **Show / hide tools** exposes are behind the panel. Adjust **Size** (50–200%) and **Opacity** (0–100%). At 0% its hit area remains active; editing keeps an invisible control findable. **Save** commits, **Cancel** reloads saved preferences, **Reset control** and **Reset all** restore -original geometry and need **Save** to confirm. Each app stores independent 2D -and 3D layouts. The game keeps running during editing. Controller connection changes +original geometry and need **Save** to confirm. Each game now stores four named +profiles under `DmPadProfiles.v2.` in UserDefaults; every profile has +independent 2D/3D maps and the selected slot survives relaunch. The first profile +migrates both legacy `DmPadControls.v1.*` maps without deleting them. Names can be +changed from the profile menu; **Save as…** copies both maps plus the visible +draft into another slot after confirmation, preserving the source. Switching +profiles releases touches and asks before discarding a dirty draft. Cancel reloads +the current saved profile; reset affects only its current layout after Save. +Profiles are local to the device, not uploaded as game progress. The game keeps running during editing. Controller connection changes cancel unsaved edits. Actual iPhone multitouch and controller behavior still need user validation after signing/installing. @@ -302,3 +309,26 @@ The clean-install check above did not start game preparation. A later user repor exposed a Foundation worker-launch failure, fixed and reproduced in 1.4.2; see [Prepare game fails at os.setsid](reference/packing.md#prepare-game-fails-at-ossetsid). The regression suite now exercises the actual Foundation launch mechanism. + +## Cloud-save investigation and API foundation + +The separate [save API prototype](../dm/cloud/README.md) records the current save +layout, transaction/case-alias constraints, portable snapshot envelope, identity +provider requirements and remaining native restore work. It stores immutable +versions with optimistic concurrency and authenticated, expiring/revocable copy +sharing; it does not access game files or modify game saves. No native cloud +controls are exposed before login, export and journaled restore are integrated. + +On 2026-10-26, upstream main was checked and still matched our integrated +`40e6baede556acd454169ac75b33d1686063f528`. No open PR or new engine/shader change +was imported for the control-profile/API work. The shared profile storage's native +Foundation tests and unsigned production iOS compile/link passed; actual touch +menus on physical iPhone/iPad still need device validation after sideloading. + +The focused profile/geometry/API checks passed (9 tests, including real RSA JWT +validation and loopback HTTP); the script suite passed 50 tests (one optional +JWT test skipped without the API dependency), and Python lint had no new +findings. A final iOS compile check compiled 178 sources with zero failures. +The gameplay regression harness was invoked but found no registered Mac build +for the local Dread folder (its `tools/dmhost` is absent); it did not run a +save/gameplay comparison. Existing game packages and save folders were untouched. diff --git a/pyproject.toml b/pyproject.toml index b4c1a29..578e0cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,15 +2,15 @@ # Scope: scripts/ and the tools under dm/. extraPaths / extra-paths are the folders the tools put on sys.path to import each other. [tool.pyright] -include = ["scripts", "dm/tools", "dm/gfx/tools", "dm/games/game.py", "dm/games/profile_guess.py", "dm/games/settings.py", "dm/ios/tools", "dm/mac"] -extraPaths = ["dm/tools", "dm/gfx/tools", "dm/games", "dm/ios/tools", "dm/mac"] +include = ["scripts", "dm/tools", "dm/gfx/tools", "dm/games/game.py", "dm/games/profile_guess.py", "dm/games/settings.py", "dm/ios/tools", "dm/mac", "dm/cloud"] +extraPaths = ["dm/tools", "dm/gfx/tools", "dm/games", "dm/ios/tools", "dm/mac", "dm/cloud"] typeCheckingMode = "basic" pythonVersion = "3.14" pythonPlatform = "Darwin" [tool.ruff] target-version = "py310" -include = ["scripts/**/*.py", "dm/tools/**/*.py", "dm/gfx/tools/**/*.py", "dm/games/*.py", "dm/ios/tools/**/*.py", "dm/mac/**/*.py"] +include = ["scripts/**/*.py", "dm/tools/**/*.py", "dm/gfx/tools/**/*.py", "dm/games/*.py", "dm/ios/tools/**/*.py", "dm/mac/**/*.py", "dm/cloud/**/*.py"] # Rules that find bugs (pyflakes, syntax, bugbear, pylint's errors and warnings, a few of ruff's own), not style: the code's dense layout (several statements on a # line, star imports of the rewriter's tables, short names) is the house style, so those rules are off, and `ruff format` is not used. diff --git a/scripts/run_save_api.py b/scripts/run_save_api.py new file mode 100644 index 0000000..107722a --- /dev/null +++ b/scripts/run_save_api.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Run the portable save API on localhost with a configured external OIDC login provider.""" +import argparse +import os +from pathlib import Path +import sys +from wsgiref.simple_server import WSGIRequestHandler, WSGIServer, make_server + +ROOT=Path(__file__).resolve().parents[1] +sys.path.insert(0,str(ROOT/'dm/cloud')) +from save_api import SaveAPI, SaveStore, oidc_verifier + +class QuietHandler(WSGIRequestHandler): + # Do not log bearer tokens, share URLs, payloads or user identity. + def log_message(self,format,*args):pass + +class TimeoutServer(WSGIServer): + def get_request(self): + connection,address=super().get_request();connection.settimeout(15);return connection,address + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--data',type=Path,required=True,help='private persistent directory outside this checkout') + parser.add_argument('--port',type=int,default=8787) + args=parser.parse_args();data=args.data.expanduser().resolve() + if data==ROOT or ROOT in data.parents:parser.error('Keep private saves outside the repository.') + verify=oidc_verifier(os.environ.get('OIDC_ISSUER'),os.environ.get('OIDC_AUDIENCE'),os.environ.get('OIDC_JWKS_URL')) + data.mkdir(parents=True,exist_ok=True);os.chmod(data,0o700) + app=SaveAPI(SaveStore(data/'saves.sqlite3'),verify) + with make_server('127.0.0.1',args.port,app,server_class=TimeoutServer,handler_class=QuietHandler) as server: + print(f'Save API listening on http://127.0.0.1:{args.port}; native cloud login/import is not connected yet.',flush=True) + server.serve_forever() + +if __name__=='__main__':main() diff --git a/scripts/tests/test_save_api.py b/scripts/tests/test_save_api.py new file mode 100644 index 0000000..8ab79a4 --- /dev/null +++ b/scripts/tests/test_save_api.py @@ -0,0 +1,146 @@ +"""Cloud API regression fixtures are synthetic; no real game/save/account is used.""" +from contextlib import closing +import hashlib +import importlib.util +import io +import json +from pathlib import Path +import secrets +import sys +import tempfile +import threading +import time +import unittest +from unittest.mock import patch +from urllib.error import HTTPError +from urllib.request import Request, urlopen +import zipfile + +ROOT=Path(__file__).resolve().parents[2] +sys.path.insert(0,str(ROOT/'dm/cloud')) +import save_api as api + +TITLE='010093801237C000' + + +def snapshot(content=b'synthetic save',path='account/progress.bin'): + record=dict(path=path,kind='file',size=len(content),sha256=hashlib.sha256(content).hexdigest()) + metadata=dict(schema=1,title_id=TITLE,build_id='a'*64,game_version='2.1.0',entries=[record]) + stream=io.BytesIO() + with zipfile.ZipFile(stream,'w',zipfile.ZIP_DEFLATED) as archive: + archive.writestr('manifest.json',json.dumps(metadata));archive.writestr(path,content) + return stream.getvalue() + + +class SaveStore(unittest.TestCase): + def setUp(self): + self.temp=tempfile.TemporaryDirectory();self.addCleanup(self.temp.cleanup) + self.store=api.SaveStore(Path(self.temp.name)/'saves.sqlite3');self.payload=snapshot() + + def test_conflicts_keep_history_and_accounts_are_isolated(self): + first=self.store.put('alice',TITLE,'default',self.payload,'') + second=self.store.put('alice',TITLE,'default',snapshot(b'new progress'),first) + for expected in ('',first): + with self.assertRaises(api.APIError) as failure:self.store.put('alice',TITLE,'default',self.payload,expected) + self.assertEqual(failure.exception.status,412) + self.assertEqual(self.store.get('alice',TITLE,'default')['id'],second) + self.assertEqual(self.store.get('alice',TITLE,'default',first)['payload'],self.payload) + self.assertEqual(self.store.list('bob',TITLE),[]) + self.assertEqual({r['revision'] for r in self.store.history('alice',TITLE,'default')},{first,second}) + self.assertEqual(self.store.history('bob',TITLE,'default'),[]) + with self.assertRaises(api.APIError):self.store.get('bob',TITLE,'default',first) + + def test_precondition_quota_and_title_checks_preserve_existing_data(self): + first=self.store.put('alice',TITLE,'default',self.payload,'') + with self.assertRaises(api.APIError) as failure:self.store.put('alice',TITLE,'default',self.payload,None) + self.assertEqual(failure.exception.status,428) + with patch.object(api,'MAX_ACCOUNT',len(self.payload)): + with self.assertRaises(api.APIError):self.store.put('alice',TITLE,'default',self.payload,first) + with self.assertRaises(api.APIError):self.store.put('alice','0100000000000000','default',self.payload,'') + self.assertEqual(self.store.get('alice',TITLE,'default')['id'],first) + + def test_sharing_is_expiring_revocable_and_copies_one_immutable_version(self): + first=self.store.put('alice',TITLE,'default',self.payload,'') + share=self.store.share('alice',TITLE,'default',first,60) + self.store.put('alice',TITLE,'default',snapshot(b'newer save'),first) + copied=self.store.copy_share('bob',share['token'],TITLE,'from-alice','') + self.assertEqual(self.store.get('bob',TITLE,'from-alice',copied)['payload'],self.payload) + with self.assertRaises(api.APIError):self.store.revoke('bob',share['id']) + with patch.object(api.time,'time',return_value=time.time()+61): + with self.assertRaises(api.APIError):self.store.copy_share('bob',share['token'],TITLE,'expired','') + self.store.revoke('alice',share['id']) + with self.assertRaises(api.APIError):self.store.copy_share('bob',share['token'],TITLE,'revoked','') + self.assertEqual(self.store.get('bob',TITLE,'from-alice',copied)['payload'],self.payload) + + def test_bad_archives_and_paths_are_rejected(self): + for path in ('../outside','account/../outside','account/prod.keys','account/game.nsp','romfs/game.bin','account/link/../../outside','account\\bad.bin'): + with self.subTest(path=path),self.assertRaises(api.APIError):api.save_manifest(snapshot(path=path)) + with self.assertRaises(api.APIError):api.save_manifest(b'not a zip') + with self.assertRaises(api.APIError):self.store.put('alice',TITLE,'../outside',self.payload,'') + # The JSON's hash does not authenticate different compressed bytes. + raw=io.BytesIO(self.payload);out=io.BytesIO() + with zipfile.ZipFile(raw) as source,zipfile.ZipFile(out,'w') as target: + for entry in source.infolist(): + content=source.read(entry) + if entry.filename=='account/progress.bin':content=b'changed save' + target.writestr(entry,content) + with self.assertRaises(api.APIError):api.save_manifest(out.getvalue()) + + def test_concurrent_writers_cannot_silently_overwrite(self): + first=self.store.put('alice',TITLE,'default',self.payload,'') + barrier=threading.Barrier(2);outcomes=[] + def write(): + barrier.wait() + try:outcomes.append(self.store.put('alice',TITLE,'default',snapshot(secrets.token_bytes(20)),first)) + except api.APIError as error:outcomes.append(error.status) + threads=[threading.Thread(target=write) for _ in range(2)] + for thread in threads:thread.start() + for thread in threads:thread.join(timeout=10) + self.assertEqual(sum(isinstance(value,str) for value in outcomes),1) + self.assertIn(412,outcomes) + + def test_real_http_requires_auth_and_preconditions(self): + from wsgiref.simple_server import make_server, WSGIRequestHandler + class Quiet(WSGIRequestHandler): + def log_message(self,format,*args):pass + def verify(token): + if token!='test-alice':raise api.APIError(401,'invalid test identity') + return 'alice' + server=make_server('127.0.0.1',0,api.SaveAPI(self.store,verify),handler_class=Quiet) + thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start() + try: + base=f'http://127.0.0.1:{server.server_port}' + with closing(urlopen(base+'/health')) as response:self.assertEqual(response.status,200) + with self.assertRaises(HTTPError) as failure:urlopen(base+'/v1/me') + self.assertEqual(failure.exception.code,401) + endpoint=base+f'/v1/games/{TITLE}/saves/default/snapshot' + headers={'Authorization':'Bearer test-alice','If-None-Match':'*'} + with closing(urlopen(Request(endpoint,data=self.payload,headers=headers,method='PUT'))) as response: + self.assertEqual(response.status,201);revision=json.load(response)['revision'] + with self.assertRaises(HTTPError) as failure:urlopen(Request(endpoint,data=self.payload,headers=headers,method='PUT')) + self.assertEqual(failure.exception.code,412) + with closing(urlopen(Request(endpoint,headers={'Authorization':'Bearer test-alice'}))) as response: + self.assertEqual(response.read(),self.payload);self.assertEqual(response.headers['ETag'],'"'+revision+'"') + finally:server.shutdown();server.server_close();thread.join(timeout=5) + + +@unittest.skipUnless(importlib.util.find_spec('jwt'),'install dm/cloud/requirements.txt for real JWT checks') +class Identity(unittest.TestCase): + def test_real_signed_token_validates_signature_issuer_audience_and_expiry(self): + import jwt + from jwt.algorithms import RSAAlgorithm + from cryptography.hazmat.primitives.asymmetric import rsa + key=rsa.generate_private_key(public_exponent=65537,key_size=2048) + issuer='https://identity.example.invalid';audience='nxnative-save-api' + verifier=api.oidc_verifier(issuer,audience,issuer+'/jwks') + jwk=jwt.PyJWK.from_json(RSAAlgorithm.to_jwk(key.public_key())) + claims=dict(iss=issuer,aud=audience,sub='alice',iat=int(time.time()),exp=int(time.time())+300) + with patch.object(jwt.PyJWKClient,'get_signing_key_from_jwt',return_value=jwk): + valid=jwt.encode(claims,key,algorithm='RS256') + self.assertEqual(verifier(valid),hashlib.sha256((issuer+'\0alice').encode()).hexdigest()) + for changes in ({'iss':'https://other.invalid'},{'aud':'other-app'},{'exp':int(time.time())-120}): + with self.subTest(changes=changes),self.assertRaises(api.APIError):verifier(jwt.encode(dict(claims,**changes),key,algorithm='RS256')) + with self.assertRaises(api.APIError):verifier(jwt.encode(claims,'synthetic-test-key-with-32-bytes-minimum',algorithm='HS256')) + other=rsa.generate_private_key(public_exponent=65537,key_size=2048) + with self.assertRaises(api.APIError):verifier(jwt.encode(claims,other,algorithm='RS256')) + with self.assertRaises(ValueError):api.oidc_verifier(issuer,audience,'https://other.invalid/jwks')