Add safe manual save export and separate device game-data updates
This commit is contained in:
1 parent
71304491d3
commit
159c960b44
15 files changed
+856
-27
No files matched your search
@@ -151,6 +151,18 @@ todas las funciones propietarias de Sideloadly. La alternativa sigue disponible.
|
||||
|
||||
Para actualizar, usa la misma cuenta y el mismo bundle ID efectivo de Sideloadly. **Instala encima; no desinstales la app**, para conservar tus partidas. Si los datos del juego no cambiaron, puedes reutilizar el `romfs` existente. En esta alternativa la firma se hace en Sideloadly y la copia en Finder; el instalador integrado realiza esas operaciones desde nuestra app.
|
||||
|
||||
## Partidas y datos desde Dispositivos
|
||||
|
||||
Cada juego reconocido muestra su imagen y ofrece **Exportar partidas** para
|
||||
crear un ZIP local de `save` y `save_device`, con el juego guardado y cerrado.
|
||||
Puedes compartir ese ZIP a mano; no hay nube ni restauración automática.
|
||||
[Formato y límites del respaldo](docs/saves-es.md#exportar-del-iphoneipad-desde-la-app-111).
|
||||
|
||||
**Copiar datos del juego** pasa el `romfs` preparado al dispositivo sin reinstalar
|
||||
la app. Si sólo cambió el editor o la app y sigue siendo la misma versión del
|
||||
juego, **Actualizar sólo la app; conservar romfs** evita reenviar esos datos.
|
||||
Un update del juego o DLC con archivos nuevos sí necesita sus datos nuevos.
|
||||
|
||||
## Actualizar un juego preparado
|
||||
|
||||
Añade tus propios archivos de actualización a la misma carpeta del juego y
|
||||
|
||||
@@ -145,6 +145,17 @@ Sideloadly features. The fallback below remains available.
|
||||
|
||||
For updates, keep the same Apple account and effective bundle ID used in Sideloadly. **Install over the existing app; do not uninstall it**, to retain its saves. Reuse the existing `romfs` only when the game data have not changed. When using this alternative, signing happens in Sideloadly and copying happens in Finder; the integrated installer performs those operations itself. See the [official Sideloadly FAQ](https://sideloadly.io/faq).
|
||||
|
||||
## Device saves and game data
|
||||
|
||||
Recognized games show their artwork and offer **Export saves** to create a local
|
||||
ZIP of committed `save`/`save_device`, after saving and closing the game. Share it
|
||||
manually; cloud sync and automatic restore are not enabled.
|
||||
[Export format and limits](docs/saves.md#export-from-a-trusted-iphoneipad-111).
|
||||
|
||||
**Copy game data** transfers prepared RomFS without reinstalling. For controls or
|
||||
app-only changes with the same game version, **Update app only; keep romfs** avoids
|
||||
recopying the data. A game update/DLC with changed assets still needs its new data.
|
||||
|
||||
## Updating a prepared game
|
||||
|
||||
Add your own update files to the same game folder and prepare it again. The app
|
||||
|
||||
@@ -58,7 +58,10 @@ struct LibraryWorkspace:View {
|
||||
if page == .devices {
|
||||
SideloadLibraryView(model:sideload,text:model.text,canInstall:!model.running && !updater.busy,prepareGame:{
|
||||
page = .prepare;model.target="ios";if !model.chooseFolder() {sideload.cancelPendingUpdate()}
|
||||
},embedded:true)
|
||||
},embedded:true,artwork:{app in
|
||||
guard let id=sideload.titleID(for:app) else {return nil}
|
||||
return GameArtworkCache.image(model.catalog.first(where:{$0.id==id})?.artworkURL)
|
||||
})
|
||||
}
|
||||
if page == .library {
|
||||
VStack(alignment:.leading,spacing:20) {
|
||||
@@ -110,7 +113,7 @@ struct LibraryWorkspace:View {
|
||||
}.frame(minWidth:1000,minHeight:640).tint(accent).background(Color(red:0.065,green:0.075,blue:0.07)).preferredColorScheme(.dark)
|
||||
.task {if model.folder != nil && library.games.isEmpty && library.error==nil {remember()}}
|
||||
.onChange(of:model.showDeviceLibrary){_,show in if show {page = .devices;model.showDeviceLibrary=false}}
|
||||
.onReceive(library.$games){games in sideload.preparedIdentifiers=games.compactMap {game in game.originalBundleID ?? game.titleID.map {"org.nxnative."+$0.lowercased()}}}
|
||||
.onReceive(library.$games){games in sideload.preparedFolders=Dictionary(games.compactMap {game in game.titleID.map {($0,game.folder)}},uniquingKeysWith:{first,_ in first});sideload.preparedIdentifiers=games.compactMap {game in game.originalBundleID ?? game.titleID.map {"org.nxnative."+$0.lowercased()}}}
|
||||
.onChange(of:model.folder){_,_ in if model.folder != nil {remember()}}
|
||||
.onChange(of:model.running){old,new in if old && !new {library.refresh()}}
|
||||
.onChange(of:scenePhase){_,phase in if phase == .active {library.refresh()}}
|
||||
|
||||
@@ -12,6 +12,31 @@ enum EasyLanguage: String, CaseIterable {
|
||||
|
||||
struct EasyStrings {
|
||||
static let english: [String:String] = [
|
||||
"Exportar partidas":"Export saves",
|
||||
"Copiar datos del juego":"Copy game data",
|
||||
"Exportando partidas…":"Exporting saves…",
|
||||
"Verificando el respaldo…":"Verifying backup…",
|
||||
"No pudimos verificar el ZIP exportado.":"Could not verify the exported ZIP.",
|
||||
"Partidas exportadas. El juego no se modificó.":"Saves exported. The game was not modified.",
|
||||
"No pudimos verificar la copia de datos.":"Could not verify the game-data copy.",
|
||||
"Datos del juego copiados. Tus partidas siguen intactas.":"Game data copied. Your saves are intact.",
|
||||
"Guarda y cierra el juego desde el selector de apps antes de continuar.":"Save and close the game from the app switcher before continuing.",
|
||||
"No pudimos verificar que el juego esté cerrado. Guárdalo y ciérralo antes de continuar.":"Could not verify that the game is closed. Save and close it before continuing.",
|
||||
"Todavía no encontramos archivos de partida guardados en este juego.":"No committed save files were found for this game yet.",
|
||||
"La partida cambió durante la exportación. Cierra el juego y vuelve a intentar.":"The save changed during export. Close the game and try again.",
|
||||
"Este guardado usa metadatos que la exportación no puede conservar. Usa un respaldo completo del dispositivo.":"This save uses metadata the exporter cannot preserve. Use a complete device backup.",
|
||||
"Elige un ZIP nuevo; no sobrescribimos respaldos.":"Choose a new ZIP; existing backups are never overwritten.",
|
||||
"El guardado supera el límite de exportación de 512 MB u 8192 entradas.":"The save exceeds the export limit of 512 MB or 8192 entries.",
|
||||
"No pudimos completar la operación. Revisa la conexión y el espacio disponible. Las partidas no se modificaron.":"Could not complete the operation. Check the connection and available storage. Saves were not modified.",
|
||||
"Ya guardé y cerré el juego":"I saved and closed the game",
|
||||
"Guarda tu progreso y cierra el juego desde el selector de apps. Copiaremos save y save_device a un ZIP local, sin modificar el dispositivo.":"Save your progress and close the game from the app switcher. We will copy save and save_device into a local ZIP without modifying the device.",
|
||||
"Copiaremos el romfs preparado en este Mac al juego seleccionado. Conservamos tus partidas y no reinstalamos la app. Mantén el juego cerrado hasta terminar.":"We will copy this Mac’s prepared romfs to the selected game. Saves are preserved and the app is not reinstalled. Keep the game closed until it finishes.",
|
||||
"Mostrar ZIP de partidas":"Show save ZIP",
|
||||
"La versión del juego cambió o no está verificada. Instala la app con sus datos nuevos.":"The game version changed or cannot be verified. Install the app with its new data.",
|
||||
"Actualizar sólo la app; conservar romfs":"Update app only; keep romfs",
|
||||
"Para cambios de controles o de la app con la misma versión del juego. Si cambió el juego o DLC, usa la instalación con datos nuevos.":"For control or app changes with the same game version. If the game or DLC changed, install with the new data.",
|
||||
"Actualiza primero la app con la misma versión que los datos preparados.":"First update the app to match the prepared game-data version.",
|
||||
|
||||
"Tus juegos instalados":"Your installed games",
|
||||
"No encontramos juegos vinculados. Añade su carpeta preparada a la biblioteca y vuelve a buscar.":"No linked games found. Add the prepared game folder to your library and search again.",
|
||||
"Mostrar más juegos":"Show more games",
|
||||
|
||||
@@ -72,6 +72,11 @@ enum RefreshPolicy {
|
||||
@Published var teams:[SideloadTeam]=[]
|
||||
@Published var apps:[InstalledGame]=[]
|
||||
var preparedIdentifiers:[String]=[]
|
||||
var preparedFolders:[String:URL]=[:]
|
||||
@Published var exportedSaves:URL?
|
||||
private var helperCapabilities:Set<String>=[]
|
||||
private var pendingExport:URL?
|
||||
private var pendingContentID:String?
|
||||
private var appQueryScope=GameAppScope()
|
||||
private var scopedDiscovery=false
|
||||
@Published var accountDevices:[AccountDevice]=[]
|
||||
@@ -133,6 +138,46 @@ enum RefreshPolicy {
|
||||
}
|
||||
connect();listDevices()
|
||||
}
|
||||
func titleID(for app:InstalledGame)->String? {
|
||||
let parts=app.id.split(separator:".")
|
||||
if parts.count>=3,parts[0].lowercased()=="org",parts[1].lowercased()=="nxnative",parts[2].count==16,parts[2].allSatisfy({$0.isHexDigit}) {return parts[2].uppercased()}
|
||||
if let job=jobs.first(where:{$0.udid==device && $0.effectiveID==app.id}) {return SaveLocations.titleID(folder:URL(fileURLWithPath:job.gameFolder))}
|
||||
if let folder=gameFolder,app.id==existing {return SaveLocations.titleID(folder:folder)}
|
||||
return nil
|
||||
}
|
||||
func dataFolder(for app:InstalledGame)->URL? {
|
||||
guard let id=titleID(for:app),let folder=preparedFolders[id],
|
||||
let data=try? Data(contentsOf:folder.appendingPathComponent("nxnative-ios.json")),
|
||||
let info=(try? JSONSerialization.jsonObject(with:data)) as? [String:Any],
|
||||
let path=info["light_dir"] as? String else {return nil}
|
||||
let romfs=URL(fileURLWithPath:path).appendingPathComponent("romfs")
|
||||
return FileManager.default.fileExists(atPath:romfs.path) ? romfs : nil
|
||||
}
|
||||
var supportsSaveExport:Bool {helperCapabilities.contains("save-export-v1")}
|
||||
var supportsContentCopy:Bool {helperCapabilities.contains("romfs-copy-v1")}
|
||||
func exportSaves(_ app:InstalledGame,destination:URL,confirmedClosed:Bool) {
|
||||
guard !busy,!device.isEmpty,appQueryScope.permits(app.id),helperCapabilities.contains("save-export-v1") else {return}
|
||||
guard !destination.deletingLastPathComponent().pathComponents.contains(".git") else {return}
|
||||
if let id=titleID(for:app),let folder=preparedFolders[id] {gameFolder=folder};logURL=nil
|
||||
pendingExport=destination;exportedSaves=nil;busy=true;error=nil;fraction=nil;startedAt=Date();phase="Exportando partidas…";record(phase)
|
||||
send(["op":"export_saves","udid":device,"effective_id":app.id,"destination":destination.path,"confirmed_closed":confirmedClosed,"original_ids":appQueryScope.originals.sorted(),"known_ids":appQueryScope.known.sorted()])
|
||||
}
|
||||
func chooseSaveDestination(_ app:InstalledGame,text:(String)->String={ $0 }) {
|
||||
guard !busy else {return}
|
||||
let panel=NSSavePanel();panel.title=app.name;panel.canCreateDirectories=true;panel.prompt=text("Exportar partidas")
|
||||
let formatter=DateFormatter();formatter.locale=Locale(identifier:"en_US_POSIX");formatter.dateFormat="yyyyMMdd-HHmmss"
|
||||
panel.nameFieldStringValue="partidas-"+(titleID(for:app) ?? "juego")+"-"+formatter.string(from:Date())+".zip"
|
||||
if let id=titleID(for:app),let folder=preparedFolders[id] {panel.directoryURL=folder}
|
||||
guard panel.runModal() == .OK,let url=panel.url else {return}
|
||||
exportSaves(app,destination:url,confirmedClosed:true)
|
||||
}
|
||||
func copyData(_ app:InstalledGame,confirmedClosed:Bool) {
|
||||
guard !busy,!device.isEmpty,appQueryScope.permits(app.id),let source=dataFolder(for:app),helperCapabilities.contains("romfs-copy-v1") else {return}
|
||||
guard let id=titleID(for:app),let folder=preparedFolders[id],let version=PreviousPreparation.load(folder:folder,target:"ios")?.version,version==app.version else {error="Actualiza primero la app con la misma versión que los datos preparados.";return}
|
||||
gameFolder=folder;logURL=nil
|
||||
pendingContentID=app.id;busy=true;error=nil;fraction=nil;startedAt=Date();phase="Copiando datos del juego…";record(phase)
|
||||
send(["op":"copy_romfs","udid":device,"effective_id":app.id,"romfs":source.path,"confirmed_closed":confirmedClosed,"game_version":app.version,"original_ids":appQueryScope.originals.sorted(),"known_ids":appQueryScope.known.sorted()])
|
||||
}
|
||||
func cancelPendingUpdate() {pendingUpdate=nil}
|
||||
func prepareUpdate(_ app:InstalledGame) {guard !busy,!device.isEmpty else {return};pendingUpdate=(device,app.id)}
|
||||
func openDevice(_ id:String) {
|
||||
@@ -212,18 +257,25 @@ enum RefreshPolicy {
|
||||
func resend() {codeRequired=false;send(["op":"resend"])}
|
||||
func signOut() {guard !busy else {return};send(["op":"logout","email":email]);defaults.removeObject(forKey:"sideloadEmail");for index in jobs.indices {jobs[index].enabled=false};saveJobs()}
|
||||
func cancel() {record("Operación cancelada; puedes volver a intentar.");if let refresh=activeRefresh,let index=jobs.firstIndex(where:{$0.id==refresh.id}) {jobs[index].retryAfter=Date().addingTimeInterval(6*3600);saveJobs()};if let process,process.isRunning {if kill(-process.processIdentifier,SIGTERM) != 0 {process.terminate()}}}
|
||||
func install(autoRefresh:Bool) {
|
||||
var canUpdateAppOnly:Bool {
|
||||
guard let folder=gameFolder,let version=PreviousPreparation.load(folder:folder,target:"ios")?.version,!version.isEmpty,
|
||||
let app=apps.first(where:{$0.id==existing}) else {return false}
|
||||
return app.version==version && romfs != nil
|
||||
}
|
||||
func install(autoRefresh:Bool,includeContent:Bool=true) {
|
||||
guard !busy,authenticated,let ipa,let gameFolder,!originalID.isEmpty,!device.isEmpty,!team.isEmpty else {return}
|
||||
if !includeContent && !canUpdateAppOnly {error="La versión del juego cambió o no está verificada. Instala la app con sus datos nuevos.";return}
|
||||
if autoRefresh && defaults.string(forKey:"sideloadEmail") != email {error="Para renovar al reiniciar, inicia sesión recordándola en el Llavero, o desactiva la renovación.";return}
|
||||
guard let effective=RefreshPolicy.identity(original:originalID,team:team,existing:existing) else {error="Ese identificador no corresponde al equipo elegido. Detenido para conservar la app anterior.";return}
|
||||
let job=RefreshJob(id:device+":"+effective,ipa:ipa.path,gameFolder:gameFolder.path,originalID:originalID,effectiveID:effective,team:team,udid:device,account:RefreshPolicy.account(email),sha256:"",expires:Date(),enabled:autoRefresh)
|
||||
startInstall(job,updating:!existing.isEmpty,content:romfs)
|
||||
startInstall(job,updating:!existing.isEmpty,content:includeContent ? romfs : nil,appOnly:!includeContent)
|
||||
}
|
||||
private func startInstall(_ job:RefreshJob,updating:Bool,content:URL?) {
|
||||
private func startInstall(_ job:RefreshJob,updating:Bool,content:URL?,appOnly:Bool=false) {
|
||||
guard FileManager.default.isReadableFile(atPath:job.ipa) else {error="No encontramos la IPA original. Conserva los archivos de preparación para renovarla.";activeRefresh=nil;return}
|
||||
if let content,!FileManager.default.fileExists(atPath:content.path) {error="Faltan los datos romfs. Prepara el juego de nuevo antes de instalarlo.";activeRefresh=nil;return}
|
||||
installIntent=job;loggedPhase="";loggedBucket = -1;logURL=nil;record("Firmando e instalando…");busy=true;error=nil;startedAt=Date();phase="Firmando e instalando…";fraction=nil
|
||||
var value:[String:Any]=["op":"install","ipa":job.ipa,"original_id":job.originalID,"team":job.team,"udid":job.udid,"work":URL(fileURLWithPath:job.gameFolder).appendingPathComponent("nxnative-tools/sideloading").path,"update":updating,"automatic":activeRefresh != nil && !manualRefresh]
|
||||
if appOnly {value["app_only"]=true}
|
||||
if updating {value["expected_id"]=job.effectiveID}
|
||||
if !job.sha256.isEmpty {value["sha256"]=job.sha256}
|
||||
if let content {value["romfs"]=content.path}
|
||||
@@ -238,7 +290,7 @@ enum RefreshPolicy {
|
||||
}
|
||||
private func handle(_ event:[String:Any]) {
|
||||
switch event["event"] as? String {
|
||||
case "hello":scopedDiscovery=(event["capabilities"] as? [String] ?? []).contains("scoped-games-v1");if (event["protocol"] as? Int) != 1 || event["architecture"] as? String != "aarch64" {error="El instalador debe compilarse para Apple silicon.";cancel()}
|
||||
case "hello":helperCapabilities=Set(event["capabilities"] as? [String] ?? []);scopedDiscovery=(event["capabilities"] as? [String] ?? []).contains("scoped-games-v1");if (event["protocol"] as? Int) != 1 || event["architecture"] as? String != "aarch64" {error="El instalador debe compilarse para Apple silicon.";cancel()}
|
||||
case "devices":
|
||||
devices=SideloadDevice.decode((event["items"] as? [[String:Any]]) ?? []);record("Búsqueda de dispositivos terminada.")
|
||||
busy=false
|
||||
@@ -267,10 +319,16 @@ enum RefreshPolicy {
|
||||
if let refresh=activeRefresh {refreshAuthenticated=true;continueRefresh(refresh)}
|
||||
case "signed_out":authenticated=false;accountDevices=[];accountDeviceTeam="";teams=[];team="";busy=false
|
||||
case "progress":
|
||||
let phases=["Registering device":"Registrando el dispositivo con Apple…","Verifying signature":"Verificando firma y vencimiento…","Preparing IPA":"Preparando IPA…","Signing app":"Firmando la app…","Installing app":"Instalando la app…","Copying game data":"Copiando datos del juego…","Game data ready":"Datos del juego listos."]
|
||||
let phases=["Exporting saves":"Exportando partidas…","Verifying saves":"Verificando el respaldo…","Registering device":"Registrando el dispositivo con Apple…","Verifying signature":"Verificando firma y vencimiento…","Preparing IPA":"Preparando IPA…","Signing app":"Firmando la app…","Installing app":"Instalando la app…","Copying game data":"Copiando datos del juego…","Game data ready":"Datos del juego listos."]
|
||||
phase=phases[event["phase"] as? String ?? ""] ?? phase;if let value=event["fraction"] as? Double,value.isFinite,value>=0,value<=1 {fraction=value}else{fraction=nil}
|
||||
let bucket=Int((fraction ?? 0)*10)
|
||||
if loggedPhase != phase || loggedBucket != bucket {record(phase,progress:fraction);loggedPhase=phase;loggedBucket=bucket}
|
||||
case "saves_exported":
|
||||
guard let requested=pendingExport,event["path"] as? String==requested.path,(event["files"] as? Int ?? 0)>0,FileManager.default.isReadableFile(atPath:requested.path) else {error="No pudimos verificar el ZIP exportado.";busy=false;pendingExport=nil;return}
|
||||
exportedSaves=requested;pendingExport=nil;busy=false;fraction=1;phase="Partidas exportadas. El juego no se modificó.";record(phase)
|
||||
case "content_copied":
|
||||
guard let expected=pendingContentID,event["effective_id"] as? String==expected else {error="No pudimos verificar la copia de datos.";busy=false;return}
|
||||
pendingContentID=nil;busy=false;fraction=1;phase="Datos del juego copiados. Tus partidas siguen intactas.";record(phase)
|
||||
case "installed":
|
||||
guard var job=installIntent,let actual=event["effective_id"] as? String,actual==job.effectiveID,let expiry=event["expires"] as? Double,expiry>Date().timeIntervalSince1970,let hash=event["sha256"] as? String,hash.count==64 else {
|
||||
error="No pudimos verificar el resultado de la instalación. Revisa el dispositivo antes de continuar.";busy=false;activeRefresh=nil;installIntent=nil;return
|
||||
@@ -280,8 +338,13 @@ enum RefreshPolicy {
|
||||
case "error":
|
||||
record("La operación se detuvo. Revisa el mensaje de error.")
|
||||
let op=event["operation"] as? String
|
||||
let codes=["game_running":"El juego sigue abierto. La renovación espera para no interrumpir tu partida.","idle_unknown":"No pudimos comprobar si el juego está abierto. Guarda, ciérralo y renueva manualmente.","identity_mismatch":"Ese identificador no corresponde al equipo elegido. Detenido para conservar la app anterior.","not_installed":"No encontramos esa app instalada. Revisa el identificador antes de actualizar.","wrong_app":"La app elegida no corresponde a este juego. No se instaló encima.","storage":"No hay espacio suficiente para preparar los datos nuevos sin borrar los anteriores.","ipa_changed":"La IPA cambió. Prepara y aprueba la actualización antes de renovarla."]
|
||||
let codes=["version_changed":"La versión del juego cambió o no está verificada. Instala la app con sus datos nuevos.","game_running":"El juego sigue abierto. La renovación espera para no interrumpir tu partida.","idle_unknown":"No pudimos comprobar si el juego está abierto. Guarda, ciérralo y renueva manualmente.","identity_mismatch":"Ese identificador no corresponde al equipo elegido. Detenido para conservar la app anterior.","not_installed":"No encontramos esa app instalada. Revisa el identificador antes de actualizar.","wrong_app":"La app elegida no corresponde a este juego. No se instaló encima.","storage":"No hay espacio suficiente para preparar los datos nuevos sin borrar los anteriores.","ipa_changed":"La IPA cambió. Prepara y aprueba la actualización antes de renovarla."]
|
||||
error=codes[event["code"] as? String ?? ""] ?? ((op=="login" || op=="restore") ? "No pudimos iniciar sesión o la sesión guardada caducó. Vuelve a iniciar sesión." : "No pudimos completar la instalación. Revisa el dispositivo, equipo y permisos. No se revocaron certificados automáticamente.")
|
||||
if op=="export_saves" || op=="copy_romfs" {
|
||||
let failures=["game_running":"Guarda y cierra el juego desde el selector de apps antes de continuar.","idle_unknown":"No pudimos verificar que el juego esté cerrado. Guárdalo y ciérralo antes de continuar.","no_saves":"Todavía no encontramos archivos de partida guardados en este juego.","save_changed":"La partida cambió durante la exportación. Cierra el juego y vuelve a intentar.","save_metadata":"Este guardado usa metadatos que la exportación no puede conservar. Usa un respaldo completo del dispositivo.","save_destination":"Elige un ZIP nuevo; no sobrescribimos respaldos.","version_changed":"Actualiza primero la app con la misma versión que los datos preparados.","save_limits":"El guardado supera el límite de exportación de 512 MB u 8192 entradas."]
|
||||
error=failures[event["code"] as? String ?? ""] ?? "No pudimos completar la operación. Revisa la conexión y el espacio disponible. Las partidas no se modificaron."
|
||||
pendingExport=nil;pendingContentID=nil
|
||||
}
|
||||
if op=="installed_apps" {apps=[];error=(event["code"] as? String)=="scope_unavailable" ? "No pudimos limitar la consulta a tus juegos. No mostramos otras aplicaciones." : "No pudimos leer tus juegos. Desbloquea el dispositivo, confía en este Mac y vuelve a buscar."}
|
||||
if op=="devices" {error="No pudimos buscar dispositivos. Revisa la conexión y vuelve a intentar."}
|
||||
if op=="account_devices" {accountDevices=[];accountDeviceTeam="";error="Apple no devolvió los dispositivos de este equipo. Revisa tu sesión y vuelve a consultar."}
|
||||
@@ -405,6 +468,10 @@ struct SideloadView:View {
|
||||
Text(text("La renovación necesita este Mac encendido, nxnative Easy abierto, conexión a Internet y el dispositivo accesible. Conserva la IPA original.")) .font(.caption).foregroundStyle(.secondary)
|
||||
Button(text(model.existing.isEmpty ? "Firmar e instalar" : "Firmar y actualizar")){model.install(autoRefresh:autoRefresh)}.buttonStyle(.borderedProminent).disabled(model.busy || model.device.isEmpty || model.team.isEmpty || model.originalID.isEmpty)
|
||||
}
|
||||
if model.canUpdateAppOnly {
|
||||
Button(text("Actualizar sólo la app; conservar romfs")){model.install(autoRefresh:autoRefresh,includeContent:false)}.disabled(model.busy)
|
||||
Text(text("Para cambios de controles o de la app con la misma versión del juego. Si cambió el juego o DLC, usa la instalación con datos nuevos.")).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Button(text("Cerrar sesión")){model.signOut()}.disabled(model.busy)
|
||||
}
|
||||
if model.codeRequired {
|
||||
@@ -428,13 +495,25 @@ struct SideloadLibraryView:View {
|
||||
let canInstall:Bool
|
||||
var prepareGame:()->Void={}
|
||||
var embedded=false
|
||||
var artwork:(InstalledGame)->NSImage?={_ in nil}
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var showInstaller=false
|
||||
@State private var forgotten:RefreshJob?
|
||||
private enum Confirmation {case export(InstalledGame),copy(InstalledGame),forget(RefreshJob)}
|
||||
@State private var confirmation:Confirmation?
|
||||
@State private var search=""
|
||||
@State private var appLimit=8
|
||||
private var matchingApps:[InstalledGame] {model.apps.filter {search.isEmpty || ($0.name+" "+$0.id).localizedCaseInsensitiveContains(search)}}
|
||||
private let accent=Color(red:0.79,green:0.95,blue:0.37)
|
||||
private var confirmationTitle:String {
|
||||
switch confirmation {case .export:return "Exportar partidas";case .copy:return "Copiar datos del juego";case .forget:return "¿Quitar este registro?";case nil:return ""}
|
||||
}
|
||||
private func confirmationMessage(_ action:Confirmation)->String {
|
||||
switch action {
|
||||
case .export:return "Guarda tu progreso y cierra el juego desde el selector de apps. Copiaremos save y save_device a un ZIP local, sin modificar el dispositivo."
|
||||
case .copy:return "Copiaremos el romfs preparado en este Mac al juego seleccionado. Conservamos tus partidas y no reinstalamos la app. Mantén el juego cerrado hasta terminar."
|
||||
case .forget:return "Sólo detiene la renovación y quita el registro de este Mac. El juego, sus archivos y sus partidas siguen en tu dispositivo."
|
||||
}
|
||||
}
|
||||
private func prepare() {prepareGame();if !embedded {dismiss()}}
|
||||
var body:some View {
|
||||
VStack(spacing:0) {
|
||||
@@ -485,9 +564,18 @@ struct SideloadLibraryView:View {
|
||||
TextField(text("Buscar por nombre o identificador"),text:$search).textFieldStyle(.roundedBorder)
|
||||
if model.apps.isEmpty && !model.busy {Text(text("No encontramos juegos vinculados. Añade su carpeta preparada a la biblioteca y vuelve a buscar.")).foregroundStyle(.secondary)}
|
||||
ForEach(Array(matchingApps.prefix(appLimit))) {app in
|
||||
HStack {
|
||||
HStack(spacing:12) {
|
||||
if let image=artwork(app) {Image(nsImage:image).resizable().scaledToFill().frame(width:76,height:50).clipped().clipShape(RoundedRectangle(cornerRadius:8))}
|
||||
else {Image(systemName:"gamecontroller").font(.title2).frame(width:76,height:50).background(.white.opacity(0.04),in:RoundedRectangle(cornerRadius:8))}
|
||||
VStack(alignment:.leading,spacing:4) {Text(app.name).font(.headline);Text(app.id+(app.version.isEmpty ? "" : " · "+app.version)).font(.caption).foregroundStyle(.secondary).textSelection(.enabled)}
|
||||
Spacer();Button(text("Actualizar con mis archivos")){model.prepareUpdate(app);prepare()}.disabled(model.busy || !canInstall)
|
||||
Spacer()
|
||||
VStack(alignment:.trailing,spacing:7) {
|
||||
Button(text("Actualizar con mis archivos")){model.prepareUpdate(app);prepare()}
|
||||
HStack {
|
||||
Button(text("Exportar partidas")){confirmation = .export(app)}.disabled(!model.supportsSaveExport)
|
||||
if model.dataFolder(for:app) != nil {Button(text("Copiar datos del juego")){confirmation = .copy(app)}.disabled(!model.supportsContentCopy)}
|
||||
}.font(.caption)
|
||||
}.disabled(model.busy || !canInstall)
|
||||
}.padding(.vertical,6)
|
||||
Divider()
|
||||
}
|
||||
@@ -503,7 +591,7 @@ struct SideloadLibraryView:View {
|
||||
Text(text(job.expires<=Date() ? "Firma vencida" : "La firma vence")+" "+job.expires.formatted(date:.abbreviated,time:.shortened)).font(.caption)
|
||||
if job.needsLogin {Text(text("Inicia sesión para renovar este juego.")).foregroundStyle(.orange)}
|
||||
Toggle(text("Renovar automáticamente antes de que caduque"),isOn:Binding(get:{model.jobs.first(where:{$0.id==job.id})?.enabled ?? false},set:{model.setRefresh(job,enabled:$0)}))
|
||||
HStack {Button(text("Renovar ahora")){model.renew(job)};Button(text("Revisar cuenta e instalación")){model.select(job);showInstaller=true};Spacer();Button(text("Quitar de esta lista")){forgotten=job}}
|
||||
HStack {Button(text("Renovar ahora")){model.renew(job)};Button(text("Revisar cuenta e instalación")){model.select(job);showInstaller=true};Spacer();Button(text("Quitar de esta lista")){confirmation = .forget(job)}}
|
||||
}.padding(16).background(.white.opacity(0.04),in:RoundedRectangle(cornerRadius:14)).disabled(model.busy || !canInstall)
|
||||
}
|
||||
}
|
||||
@@ -515,10 +603,18 @@ struct SideloadLibraryView:View {
|
||||
.onChange(of:model.device){_,_ in appLimit=8}
|
||||
.task {if canInstall && !model.busy {model.listDevices()}}
|
||||
.sheet(isPresented:$showInstaller){SideloadView(model:model,text:text)}
|
||||
.alert(text("¿Quitar este registro?"),isPresented:Binding(get:{forgotten != nil},set:{if !$0 {forgotten=nil}})) {
|
||||
Button(text("Cancelar"),role:.cancel){forgotten=nil}
|
||||
Button(text("Quitar de esta lista"),role:.destructive){if let job=forgotten {model.forget(job)};forgotten=nil}
|
||||
} message:{Text(text("Sólo detiene la renovación y quita el registro de este Mac. El juego, sus archivos y sus partidas siguen en tu dispositivo."))}
|
||||
.alert(text(confirmationTitle),isPresented:Binding(get:{confirmation != nil},set:{if !$0 {confirmation=nil}}),presenting:confirmation) {action in
|
||||
Button(text("Cancelar"),role:.cancel){confirmation=nil}
|
||||
switch action {
|
||||
case .export(let app):
|
||||
Button(text("Ya guardé y cerré el juego")){confirmation=nil;DispatchQueue.main.async {model.chooseSaveDestination(app,text:text)}}
|
||||
case .copy(let app):
|
||||
Button(text("Ya guardé y cerré el juego")){confirmation=nil;model.copyData(app,confirmedClosed:true)}
|
||||
case .forget(let job):
|
||||
Button(text("Quitar de esta lista"),role:.destructive){model.forget(job);confirmation=nil}
|
||||
}
|
||||
} message:{action in Text(text(confirmationMessage(action)))}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -534,6 +630,7 @@ struct InstallActivityView:View {
|
||||
TimelineView(.periodic(from:.now,by:1)){context in Text(text("Tiempo transcurrido")+" "+String(Int(context.date.timeIntervalSince(model.startedAt ?? context.date)))+" s").font(.caption).foregroundStyle(.secondary)}
|
||||
}
|
||||
if let error=model.error {Label(text(error),systemImage:"exclamationmark.triangle").foregroundStyle(.orange).textSelection(.enabled)}
|
||||
if let export=model.exportedSaves {Button(text("Mostrar ZIP de partidas")){NSWorkspace.shared.activateFileViewerSelecting([export])}}
|
||||
if !model.logs.isEmpty {
|
||||
DisclosureGroup(text("Actividad de instalación"),isExpanded:$expanded) {
|
||||
HStack {Button(text("Copiar registro")){model.copyLogs(text:text)};if let url=model.logURL {Button(text("Mostrar registro guardado")){NSWorkspace.shared.activateFileViewerSelecting([url])}};Spacer()}
|
||||
|
||||
@@ -11,15 +11,15 @@ import Foundation
|
||||
let helper=root.appendingPathComponent("helper")
|
||||
try """
|
||||
#!/usr/bin/python3
|
||||
import sys,json,time
|
||||
import sys,json,time,zipfile,os
|
||||
def emit(value):print(json.dumps(value),flush=True)
|
||||
emit({'event':'hello','protocol':1,'architecture':'aarch64','capabilities':['scoped-games-v1']})
|
||||
emit({'event':'hello','protocol':1,'architecture':'aarch64','capabilities':['scoped-games-v1','save-export-v1','romfs-copy-v1']})
|
||||
for line in sys.stdin:
|
||||
value=json.loads(line)
|
||||
if value['op']=='devices':emit({'event':'devices','items':[{'udid':'SYNTHETIC-DEVICE','name':'Test iPad','model':'iPad-test','os':'26.0','connections':['Wi-Fi']},{'udid':'SYNTHETIC-DEVICE','name':'Test iPad','model':'iPad-test','os':'26.0','connections':['USB','Wi-Fi']}]})
|
||||
elif value['op']=='installed_apps':
|
||||
assert value.get('original_ids') and 'known_ids' in value
|
||||
emit({'event':'installed_apps','items':[{'id':'org.game.custom','name':'Test game'},{'id':'com.unrelated.private','name':'Test game'},{'id':'org.game.customevil','name':'Test game'}]})
|
||||
emit({'event':'installed_apps','items':[{'id':'org.game.custom','name':'Test game','version':'2.1.0'},{'id':'com.unrelated.private','name':'Test game'},{'id':'org.game.customevil','name':'Test game'}]})
|
||||
elif value['op']=='login':
|
||||
assert value['password']=='synthetic-password-only'
|
||||
emit({'event':'two_factor'})
|
||||
@@ -30,8 +30,17 @@ import Foundation
|
||||
elif value['op']=='install':
|
||||
assert value['automatic']==False and value['update']==True
|
||||
assert value['expected_id']=='org.game.custom'
|
||||
assert value['sha256']=='a'*64 and 'romfs' not in value
|
||||
assert 'romfs' not in value
|
||||
if not value.get('app_only'):assert value['sha256']=='a'*64
|
||||
emit({'event':'installed','effective_id':value['expected_id'],'expires':time.time()+86400,'sha256':'a'*64})
|
||||
elif value['op']=='export_saves':
|
||||
assert value['effective_id']=='org.game.custom' and value['confirmed_closed']==True
|
||||
with zipfile.ZipFile(value['destination'],'x') as z:z.writestr('save/progress.bin',b'synthetic progress')
|
||||
os.chmod(value['destination'],0o600)
|
||||
emit({'event':'saves_exported','path':value['destination'],'files':1,'bytes':18})
|
||||
elif value['op']=='copy_romfs':
|
||||
assert value['effective_id']=='org.game.custom' and value['game_version']=='2.1.0' and value['confirmed_closed']==True
|
||||
emit({'event':'content_copied','effective_id':value['effective_id']})
|
||||
elif value['op']=='account_devices':
|
||||
assert value['team']=='ABCDE12345'
|
||||
emit({'event':'account_devices','team':value['team'],'items':[{'udid':'REGISTERED-DEVICE','name':'Synthetic iPhone','status':'a'}]})
|
||||
@@ -96,14 +105,26 @@ import Foundation
|
||||
assert(IOSPackageOutput.load(record:packaging,split:false)?.romfs==nil)
|
||||
assert(IOSPackageOutput.load(record:packaging,split:true)?.romfs==lightweight.appendingPathComponent("romfs"))
|
||||
assert(IOSPackageOutput.load(record:["full_ipa":ipa.path],split:false)==nil)
|
||||
try JSONSerialization.data(withJSONObject:["schema":1,"bundle_id":"org.game"]).write(to:folder.appendingPathComponent("nxnative-ios.json"))
|
||||
let selected=InstalledGame(id:"org.game.custom",name:"Test game")
|
||||
try FileManager.default.createDirectory(at:lightweight.appendingPathComponent("romfs"),withIntermediateDirectories:true)
|
||||
try JSONSerialization.data(withJSONObject:["schema":1,"bundle_id":"org.game","version":"2.1.0","light_dir":lightweight.path]).write(to:folder.appendingPathComponent("nxnative-ios.json"))
|
||||
let selected=InstalledGame(id:"org.game.custom",name:"Test game",version:"2.1.0")
|
||||
model.prepareUpdate(selected);model.configure(ipa:ipa,folder:folder,romfs:nil)
|
||||
try await wait {!model.busy && model.apps.count==1}
|
||||
assert(model.existing==selected.id && model.device=="SYNTHETIC-DEVICE")
|
||||
assert(model.logURL != nil)
|
||||
let permissions=try FileManager.default.attributesOfItem(atPath:model.logURL!.path)[.posixPermissions] as! NSNumber
|
||||
assert(permissions.intValue==0o600)
|
||||
model.preparedFolders=["010093801237C000":folder];model.romfs=lightweight.appendingPathComponent("romfs")
|
||||
assert(model.titleID(for:InstalledGame(id:"org.nxnative.010093801237c000.ABCDE12345",name:"Metroid"))=="010093801237C000")
|
||||
assert(model.dataFolder(for:selected)?.path==model.romfs?.path)
|
||||
assert(model.canUpdateAppOnly)
|
||||
model.install(autoRefresh:false,includeContent:false);try await wait {!model.busy}
|
||||
let backup=root.appendingPathComponent("progress.zip")
|
||||
model.exportSaves(selected,destination:backup,confirmedClosed:true);try await wait {!model.busy && model.exportedSaves != nil};assert(model.exportedSaves==backup)
|
||||
model.copyData(selected,confirmedClosed:true);try await wait {!model.busy};assert(model.fraction==1 && model.error==nil)
|
||||
model.copyData(InstalledGame(id:selected.id,name:selected.name,version:"9.0"),confirmedClosed:true);assert(!model.busy && model.error != nil)
|
||||
let unrelated=root.appendingPathComponent("unrelated.zip")
|
||||
model.exportSaves(InstalledGame(id:"com.unrelated.private",name:selected.name),destination:unrelated,confirmedClosed:true);assert(!FileManager.default.fileExists(atPath:unrelated.path))
|
||||
model.cancel()
|
||||
print("Sideload client PASS: multi-game persistence, explicit manual refresh with original identity/hash and no RomFS recopy, account guard, metadata-only removal; private stdio login/2FA, explicit team selection, logout, refresh account/expiry/backoff guards, custom update identity and save-location mapping")
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.10.1",
|
||||
"build": 16,
|
||||
"version": "1.11.0",
|
||||
"build": 17,
|
||||
"minimumMacOSMajor": 26
|
||||
}
|
||||
@@ -41,6 +41,11 @@ appear everywhere without being forgotten. No recurring schedule is implied.
|
||||
Never add automatic certificate revocation, account/team changes, save-folder
|
||||
writes or password logging. Helper tests use synthetic credentials and no device;
|
||||
compilation never counts as successful Apple login or physical installation.
|
||||
Preserve read-only committed-save export as a separate capability from
|
||||
RomFS transfer: no restore, save-folder writes or silent metadata loss.
|
||||
Keep duplicate hash/inventory checks, new-backup-only publication and alias
|
||||
refusal. Source/installed game versions must agree for app-only renewal and
|
||||
standalone content copy; version equality does not prove asset equality.
|
||||
Keep local saves and control preferences separate from the optional save API;
|
||||
cloud login/export/restore must not be advertised until native integration and
|
||||
recovery are configured and device-tested. Never package a private save database.
|
||||
|
||||
@@ -512,3 +512,37 @@ navigation request without attaching a sheet. The production ARM64 bundle
|
||||
compiled; signing/install execution on real devices was not part of this fix.
|
||||
The regression harness was invoked again but found no registered Mac Dread
|
||||
build; this release adds no gameplay validation claim.
|
||||
|
||||
## Manual device saves and separate game content (1.11.0)
|
||||
|
||||
Devices resolves game artwork by the prepared TitleID and offers a read-only
|
||||
committed-save ZIP export. Export requires a closed game, reads only save and
|
||||
save_device, verifies a stable inventory and a second content hash pass, and
|
||||
never replaces an existing local backup. It rejects aliases requiring guest
|
||||
metadata that AFC cannot preserve. Cloud synchronization and automatic restore
|
||||
remain deferred. A read-only physical iPhone export, with the process guard
|
||||
verified rather than manually overridden, produced four files (38,362 bytes);
|
||||
ZIP integrity and all manifest hashes passed. The private ZIP is outside Git.
|
||||
|
||||
Copy game data now invokes the existing RomFS staging/recovery transfer without
|
||||
reinstalling the app or touching saves. The prepared and installed game versions
|
||||
must agree. An explicit app-only update keeps existing RomFS for controls/runtime
|
||||
changes at the same game version; changed game/DLC assets still require new data.
|
||||
No physical content transfer, Apple account login, signing or installation was
|
||||
performed over the user's working game in this release. Physical transfer
|
||||
interruption/recovery and native signing validation remain outstanding.
|
||||
|
||||
Validation: 18 Rust tests passed, including actual ZIP pipeline/manifest/hash/
|
||||
permissions roundtrip and changing-source rejection. All 66 script cases completed
|
||||
with one optional JWT dependency skip; compiled Swift client and production
|
||||
SwiftUI/helper builds passed. Documentation and the 78-profile catalog passed;
|
||||
Python lint retained 414 baseline findings with no additions. The gameplay
|
||||
harness was invoked with the local game root, but no registered Mac Dread build
|
||||
was available; this release claims no new gameplay test. Host and shaders did
|
||||
not change.
|
||||
|
||||
The explicitly approved obsolete backup was permanently removed after checking
|
||||
for protected data, freeing approximately 35.7 GB before final build-cache
|
||||
cleanup. Original XCI/update NSP, the lightweight unsigned IPA, RomFS, source
|
||||
checkout and exported saves remain outside that deletion. A private cleanup
|
||||
receipt records paths, available space and the IPA hash beside the game tools.
|
||||
@@ -102,3 +102,32 @@ respaldo permite trasladar la base a otro servidor conservando versiones y
|
||||
permisos. [Configuración, respaldo y traslado](../dm/cloud/README.md#portable-hosting-and-migration).
|
||||
Esto no activa sincronización en los juegos: faltan el inicio de sesión nativo y
|
||||
la exportación/restauración segura de partidas en cada dispositivo.
|
||||
|
||||
|
||||
## Exportar del iPhone/iPad desde la app (1.11)
|
||||
|
||||
En **Dispositivos**, busca tu juego reconocido y pulsa **Exportar partidas**.
|
||||
Guarda dentro del juego y ciérralo desde el selector de apps; confirma ese paso y
|
||||
elige un ZIP nuevo. No necesitas iniciar sesión con Apple para esta lectura en
|
||||
un dispositivo ya emparejado y de confianza. **Mostrar ZIP de partidas** abre su
|
||||
ubicación para compartirlo manualmente, por ejemplo mediante AirDrop.
|
||||
|
||||
La herramienta lee únicamente `Documents/save` y `Documents/save_device` cuando
|
||||
existe. No copia `romfs`, cachés, `.work`, `.new` ni preferencias de controles.
|
||||
Verifica inventarios, tamaños y hashes en dos lecturas antes de publicar el ZIP;
|
||||
si la partida cambia, no publica un respaldo incompleto. El ZIP incluye esas
|
||||
carpetas y `nxnative-save-export.json`, con versión del juego y comprobaciones.
|
||||
No modifica el dispositivo ni sobrescribe otro ZIP. La nube sigue desconectada.
|
||||
|
||||
Si detecta el juego abierto, se detiene. Cuando el sistema no puede comprobarlo,
|
||||
la exportación manual depende de tu confirmación de haberlo cerrado; no uses
|
||||
esta opción con una partida activa. Se rechazan enlaces, archivos especiales,
|
||||
credenciales y alias `.dm-case-*` cuyos metadatos no podemos conservar por AFC.
|
||||
Límites actuales: 512 MB y 8192 entradas. La interrupción forzada puede dejar un
|
||||
archivo temporal privado, pero no un ZIP final presentado como correcto.
|
||||
|
||||
Para compartir, manda el ZIP completo. Restaurar es todavía manual y depende de
|
||||
que coincidan juego, versión y formato; no prometemos conversión de partidas de
|
||||
otros emuladores. Antes de reemplazar archivos desde Finder, conserva un respaldo
|
||||
del destino y cierra también ese juego. Esta versión no añade restauración
|
||||
automática ni modifica tus partidas al preparar o copiar datos.
|
||||
@@ -125,3 +125,31 @@ move snapshot history and ownership to another server.
|
||||
[Configuration, backup and relocation](../dm/cloud/README.md#portable-hosting-and-migration).
|
||||
This does not activate in-game sync: native login and safe device snapshot/import
|
||||
flows are still pending.
|
||||
|
||||
|
||||
## Export from a trusted iPhone/iPad (1.11)
|
||||
|
||||
In **Devices**, choose **Export saves** for a recognized game, save and close it
|
||||
from the app switcher, confirm and select a new ZIP. Apple-account login is not
|
||||
required for this read on an already paired/trusted device. **Show save ZIP**
|
||||
reveals the result for manual sharing.
|
||||
|
||||
A separate read-only AFC capability exports only committed `Documents/save` and
|
||||
optional `Documents/save_device`. It excludes game content, working transactions,
|
||||
preferences and credentials. Inventories/sizes and a second SHA-256 read must
|
||||
remain stable before a private ZIP is atomically published without overwriting a
|
||||
backup. The package contains the same folder names plus
|
||||
`nxnative-save-export.json` with game identity/version, hashes and source times.
|
||||
There is no device write or restore operation in this module.
|
||||
|
||||
Observed running games stop export. If process inventory is unavailable, manual
|
||||
export requires the player's explicit closed-game acknowledgement. Symlinks,
|
||||
special files and `.dm-case-*` aliases are refused because AFC cannot preserve
|
||||
the engine's case-alias xattr; use a complete device backup for those cases.
|
||||
Limits are 512 MB and 8192 entries. A forced termination may leave a private
|
||||
partial temporary file, never a falsely successful final ZIP.
|
||||
|
||||
Manual sharing/restoration still needs matching game/version/format and a backup
|
||||
of the recipient's original files. Control presets are separate. This package
|
||||
is not yet connected to the provider-independent cloud API or its nxsave schema.
|
||||
There is no automatic import or universal emulator conversion claim.
|
||||
@@ -265,3 +265,45 @@ Local read-only validation with the Metroid preparation scope returned only
|
||||
No app was installed, updated, uninstalled or accessed through HouseArrest during
|
||||
that check. Rust tests cover matching boundaries and empty-scope/no-device access;
|
||||
compiled Swift/protocol tests cover scope forwarding and unrelated-row rejection.
|
||||
|
||||
|
||||
## Save export, standalone content copy and app-only updates (1.11)
|
||||
|
||||
Recognized device games now resolve artwork through the local profile catalog
|
||||
using their Title ID. No name-based ownership matching was added.
|
||||
|
||||
**Export saves** is a separate read-only HouseArrest/AFC module. Its path boundary
|
||||
can address only committed `save`/`save_device`, never game content, transaction
|
||||
siblings, credential files or arbitrary container paths. It refuses unsupported
|
||||
case aliases, checks source stability with inventories plus two hash reads and
|
||||
publishes a local ZIP atomically without overwriting another backup. Directory
|
||||
permissions remain traversable (0700); files/the archive are private (0600).
|
||||
Destinations inside Git checkouts are refused. No restore/device write endpoint
|
||||
was introduced. [Export format and limits](saves.md#export-from-a-trusted-iphoneipad-111).
|
||||
A real read-only Metroid export was verified locally: four committed files,
|
||||
38,362 uncompressed bytes, ZIP CRCs and per-file hashes checked. The private ZIP
|
||||
is outside this repo and is not published. The device was not modified.
|
||||
|
||||
**Copy game data** transfers the local preparation's RomFS to the selected
|
||||
recognized game without signing/reinstalling or asking for an Apple login. The
|
||||
client and helper check the installed game version; keep the game closed.
|
||||
The existing RomFS-only staging/recovery capability remains unchanged, so this
|
||||
operation cannot write to saves. It has not been rerun over the working Metroid
|
||||
content just to test this UI; physical interrupted-transfer recovery remains a
|
||||
validation item.
|
||||
|
||||
**Update app only; keep romfs** is offered for an existing installation when the
|
||||
reported game version matches the local preparation. It sends no RomFS and keeps
|
||||
existing data. The helper checks the IPA's actual version against the installed
|
||||
app again; scheduled renewals now also stop on a version mismatch. This is not
|
||||
a full asset-difference detector: new game assets, mods or DLC need their new
|
||||
data even if the displayed version happens to be unchanged.
|
||||
|
||||
For controls/runtime-only changes: prepare again with valid cached work, then
|
||||
choose app-only update. For a new game update: place your own new NSP update with
|
||||
the base files, prepare again and install the app plus its newly prepared data.
|
||||
Retain the same effective bundle ID/team/account and never uninstall the previous
|
||||
app. Support for the new game version still depends on the engine/profile.
|
||||
A lightweight IPA still needs the complete RomFS on the phone; it makes app
|
||||
signing/reinstallation smaller, not the game data optional. Full-IPA mode embeds
|
||||
those data and retransfers the larger app on renewal.
|
||||
@@ -83,7 +83,7 @@ async fn lookup(
|
||||
Ok(apps.into_iter().filter_map(|(id,app)|{
|
||||
let app=app.as_dictionary()?;
|
||||
let name=app.get("CFBundleDisplayName").or_else(||app.get("CFBundleName")).and_then(Plist::as_string).unwrap_or(&id);
|
||||
Some(json!({"id":id,"name":name,"version":app.get("CFBundleShortVersionString").and_then(Plist::as_string).unwrap_or("")}))
|
||||
Some(json!({"id":id,"name":name,"executable":app.get("CFBundleExecutable").and_then(Plist::as_string).unwrap_or(""),"version":app.get("CFBundleShortVersionString").and_then(Plist::as_string).unwrap_or("")}))
|
||||
}).collect())
|
||||
}
|
||||
pub async fn installed(udid: &str, scope: &Scope) -> Result<Vec<Value>, Report> {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
mod games;
|
||||
mod idle;
|
||||
mod romfs;
|
||||
mod saves;
|
||||
use idevice::{
|
||||
IdeviceService,
|
||||
provider::{IdeviceProvider, UsbmuxdProvider},
|
||||
@@ -436,6 +437,19 @@ async fn install(session: &mut Session, request: &Value) -> Result<(), Report> {
|
||||
.ok_or_else(|| report!("Missing game executable"))?
|
||||
.to_string();
|
||||
let automatic = request["automatic"].as_bool() == Some(true);
|
||||
if automatic || request["app_only"].as_bool() == Some(true) {
|
||||
let version = metadata
|
||||
.get("CFBundleShortVersionString")
|
||||
.and_then(plist::Value::as_string);
|
||||
let installed_version = apps
|
||||
.iter()
|
||||
.find(|a| a["id"].as_str() == Some(effective.as_str()))
|
||||
.and_then(|a| a["version"].as_str())
|
||||
.filter(|s| !s.is_empty());
|
||||
if version.is_none() || version != installed_version {
|
||||
bail!("Game version differs from installed data")
|
||||
}
|
||||
}
|
||||
if automatic {
|
||||
idle::require_idle(udid, &executable)?;
|
||||
}
|
||||
@@ -507,12 +521,56 @@ async fn install(session: &mut Session, request: &Value) -> Result<(), Report> {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
// Never terminate a game to copy/export. A user may acknowledge an unknown
|
||||
// process inventory; an observed running process always stops the operation.
|
||||
fn require_closed(udid: &str, game: &Value, confirmed: bool) -> Result<(), Report> {
|
||||
let executable = game["executable"]
|
||||
.as_str()
|
||||
.filter(|s| !s.is_empty() && !s.contains(['/', '\\']));
|
||||
if let Some(executable) = executable {
|
||||
match idle::require_idle(udid, executable) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(error) if error.to_string().contains("Game is running") => return Err(error),
|
||||
Err(error) if !confirmed => return Err(error),
|
||||
_ => {}
|
||||
}
|
||||
} else if !confirmed {
|
||||
bail!("Cannot verify game process state")
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn copy_game_content(request: &Value) -> Result<(), Report> {
|
||||
let udid = text(request, "udid")?;
|
||||
let effective = text(request, "effective_id")?;
|
||||
let scope: games::Scope = serde_json::from_value(request.clone())?;
|
||||
let apps = games::installed(udid, &scope).await?;
|
||||
let game = apps
|
||||
.iter()
|
||||
.find(|app| app["id"].as_str() == Some(effective))
|
||||
.ok_or_else(|| report!("Existing app identity was not found"))?;
|
||||
if request["game_version"].as_str().filter(|v| !v.is_empty()) != game["version"].as_str() {
|
||||
bail!("Game version differs from installed data")
|
||||
}
|
||||
require_closed(
|
||||
udid,
|
||||
game,
|
||||
request["confirmed_closed"].as_bool() == Some(true),
|
||||
)?;
|
||||
romfs::transfer(
|
||||
&provider(udid).await?,
|
||||
effective,
|
||||
Path::new(text(request, "romfs")?),
|
||||
)
|
||||
.await?;
|
||||
emit(json!({"event":"content_copied","effective_id":effective}));
|
||||
Ok(())
|
||||
}
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let _ = isideload::init();
|
||||
emit(
|
||||
json!({"event":"hello","protocol":1,"architecture":std::env::consts::ARCH,"capabilities":["scoped-games-v1"]}),
|
||||
json!({"event":"hello","protocol":1,"architecture":std::env::consts::ARCH,"capabilities":["scoped-games-v1","save-export-v1","romfs-copy-v1"]}),
|
||||
);
|
||||
let mut session: Option<Session> = None;
|
||||
while let Ok(mut request) = read_request() {
|
||||
@@ -552,6 +610,11 @@ async fn main() {
|
||||
}.await,
|
||||
None => Err(report!("Sign in first")),
|
||||
},
|
||||
"export_saves" => async {
|
||||
let scope:games::Scope=serde_json::from_value(request.clone())?;
|
||||
saves::export(text(&request,"udid")?,text(&request,"effective_id")?,&scope,Path::new(text(&request,"destination")?),request["confirmed_closed"].as_bool()==Some(true)).await
|
||||
}.await,
|
||||
"copy_romfs" => copy_game_content(&request).await,
|
||||
"install" => match &mut session {
|
||||
Some(value) => install(value, &request).await,
|
||||
None => Err(report!("Sign in first")),
|
||||
@@ -578,7 +641,19 @@ async fn main() {
|
||||
}
|
||||
});
|
||||
let detail = error.to_string();
|
||||
let code = if detail.contains("discovery scope") {
|
||||
let code = if detail.contains("Game version differs") {
|
||||
"version_changed"
|
||||
} else if detail.contains("No committed save") {
|
||||
"no_saves"
|
||||
} else if detail.contains("Save changed") {
|
||||
"save_changed"
|
||||
} else if detail.contains("Unsupported save metadata") {
|
||||
"save_metadata"
|
||||
} else if detail.contains("Save destination") {
|
||||
"save_destination"
|
||||
} else if detail.contains("Save export exceeds") {
|
||||
"save_limits"
|
||||
} else if detail.contains("discovery scope") {
|
||||
"scope_unavailable"
|
||||
} else if detail.contains("Game is running") {
|
||||
"game_running"
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
//! Read-only committed-save export. This module has no device write/delete operation.
|
||||
use idevice::{
|
||||
IdeviceError, IdeviceService,
|
||||
afc::{AfcClient, errors::AfcError, opcode::AfcFopenMode},
|
||||
house_arrest::HouseArrestClient,
|
||||
};
|
||||
use rootcause::prelude::*;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
io::Write,
|
||||
path::{Component, Path},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use zip::{ZipWriter, write::SimpleFileOptions};
|
||||
const MAX_BYTES: u64 = 512 * 1024 * 1024;
|
||||
const MAX_ENTRIES: usize = 8192;
|
||||
fn remote(path: &str) -> Result<String, Report> {
|
||||
let parts: Vec<_> = path.split('/').collect();
|
||||
if !matches!(parts.first(), Some(&"save" | &"save_device"))
|
||||
|| parts.len() > 32
|
||||
|| path.len() > 1024
|
||||
|| path.contains(['\\', ':', '\0'])
|
||||
|| Path::new(path)
|
||||
.components()
|
||||
.any(|c| !matches!(c, Component::Normal(_)))
|
||||
|| parts
|
||||
.iter()
|
||||
.any(|p| p.is_empty() || *p == "." || *p == ".." || p.starts_with(".dm-case-"))
|
||||
{
|
||||
bail!("Unsupported save metadata or unsafe path")
|
||||
}
|
||||
for part in &parts {
|
||||
let lower = part.to_ascii_lowercase();
|
||||
if matches!(lower.as_str(), "prod.keys" | "title.keys")
|
||||
|| Path::new(&lower)
|
||||
.extension()
|
||||
.and_then(|v| v.to_str())
|
||||
.is_some_and(|v| {
|
||||
matches!(
|
||||
v,
|
||||
"p12" | "mobileprovision" | "ipa" | "nsp" | "xci" | "nsz" | "xcz"
|
||||
)
|
||||
})
|
||||
{
|
||||
bail!("Unsupported save metadata or credential file")
|
||||
}
|
||||
}
|
||||
Ok(format!("/Documents/{path}"))
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct Entry {
|
||||
path: String,
|
||||
size: u64,
|
||||
directory: bool,
|
||||
modified: String,
|
||||
created: String,
|
||||
}
|
||||
struct SaveReader(AfcClient);
|
||||
impl SaveReader {
|
||||
async fn stat(&mut self, path: &str) -> Result<Option<Entry>, Report> {
|
||||
let info = match self.0.get_file_info_raw(remote(path)?).await {
|
||||
Ok(info) => info,
|
||||
Err(IdeviceError::Afc(AfcError::ObjectNotFound)) => return Ok(None),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let kind = info.get("st_ifmt").map(String::as_str).unwrap_or("");
|
||||
if !matches!(kind, "S_IFREG" | "S_IFDIR") || info.contains_key("st_link_target") {
|
||||
bail!("Unsupported save metadata or special file")
|
||||
}
|
||||
Ok(Some(Entry {
|
||||
path: path.into(),
|
||||
size: info
|
||||
.get("st_size")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.ok_or_else(|| report!("Missing save metadata"))?,
|
||||
directory: kind == "S_IFDIR",
|
||||
modified: info
|
||||
.get("st_mtime")
|
||||
.cloned()
|
||||
.ok_or_else(|| report!("Missing save metadata"))?,
|
||||
created: info.get("st_birthtime").cloned().unwrap_or_default(),
|
||||
}))
|
||||
}
|
||||
async fn inventory(&mut self) -> Result<Vec<Entry>, Report> {
|
||||
let mut pending = vec!["save".to_string(), "save_device".to_string()];
|
||||
let mut entries = Vec::new();
|
||||
let mut total = 0u64;
|
||||
while let Some(path) = pending.pop() {
|
||||
let Some(entry) = self.stat(&path).await? else {
|
||||
if path == "save" || path == "save_device" {
|
||||
continue;
|
||||
} else {
|
||||
bail!("Save changed during export")
|
||||
}
|
||||
};
|
||||
if entry.directory {
|
||||
for name in self.0.list_dir(remote(&path)?).await? {
|
||||
if name == "." || name == ".." {
|
||||
continue;
|
||||
}
|
||||
if name.contains('/') {
|
||||
bail!("Unsafe save entry")
|
||||
}
|
||||
let child = format!("{path}/{name}");
|
||||
remote(&child)?;
|
||||
pending.push(child);
|
||||
}
|
||||
} else {
|
||||
total = total
|
||||
.checked_add(entry.size)
|
||||
.ok_or_else(|| report!("Save export exceeds limits"))?;
|
||||
}
|
||||
entries.push(entry);
|
||||
if entries.len() + pending.len() > MAX_ENTRIES || total > MAX_BYTES {
|
||||
bail!("Save export exceeds limits")
|
||||
}
|
||||
}
|
||||
entries.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
Ok(entries)
|
||||
}
|
||||
async fn read(
|
||||
&mut self,
|
||||
entry: &Entry,
|
||||
mut output: Option<&mut ZipWriter<&mut std::fs::File>>,
|
||||
) -> Result<String, Report> {
|
||||
let mut file = self
|
||||
.0
|
||||
.open(remote(&entry.path)?, AfcFopenMode::RdOnly)
|
||||
.await?;
|
||||
let mut remaining = entry.size;
|
||||
let mut hash = Sha256::new();
|
||||
while remaining > 0 {
|
||||
let bytes = file.read_n(remaining.min(1024 * 1024) as usize).await?;
|
||||
if bytes.is_empty() || bytes.len() as u64 > remaining {
|
||||
bail!("Save changed during export")
|
||||
}
|
||||
hash.update(&bytes);
|
||||
if let Some(writer) = output.as_mut() {
|
||||
writer.write_all(&bytes)?;
|
||||
}
|
||||
remaining -= bytes.len() as u64;
|
||||
}
|
||||
file.close().await?;
|
||||
Ok(hex::encode(hash.finalize()))
|
||||
}
|
||||
}
|
||||
#[derive(Serialize)]
|
||||
struct Record {
|
||||
path: String,
|
||||
directory: bool,
|
||||
size: u64,
|
||||
sha256: Option<String>,
|
||||
modified_ns: String,
|
||||
created_ns: String,
|
||||
}
|
||||
fn publish(temporary: tempfile::NamedTempFile, destination: &Path) -> Result<(), Report> {
|
||||
temporary.as_file().sync_all()?;
|
||||
temporary
|
||||
.persist_noclobber(destination)
|
||||
.map_err(|_| report!("Save destination exists or cannot be created"))?;
|
||||
Ok(())
|
||||
}
|
||||
pub async fn export(
|
||||
udid: &str,
|
||||
effective: &str,
|
||||
scope: &super::games::Scope,
|
||||
destination: &Path,
|
||||
confirmed_closed: bool,
|
||||
) -> Result<(), Report> {
|
||||
let games = super::games::installed(udid, scope).await?;
|
||||
let game = games
|
||||
.iter()
|
||||
.find(|g| g["id"].as_str() == Some(effective))
|
||||
.ok_or_else(|| report!("Existing app identity was not found"))?;
|
||||
super::require_closed(udid, game, confirmed_closed)?;
|
||||
let provider = super::provider(udid).await?;
|
||||
let house = HouseArrestClient::connect(&provider).await?;
|
||||
let mut reader = SaveReader(house.vend_documents(effective).await?);
|
||||
write_snapshot(&mut reader, effective, game, destination, confirmed_closed).await
|
||||
}
|
||||
trait CommittedSource {
|
||||
async fn entries(&mut self) -> Result<Vec<Entry>, Report>;
|
||||
async fn hash_to(
|
||||
&mut self,
|
||||
entry: &Entry,
|
||||
output: Option<&mut ZipWriter<&mut std::fs::File>>,
|
||||
) -> Result<String, Report>;
|
||||
}
|
||||
impl CommittedSource for SaveReader {
|
||||
async fn entries(&mut self) -> Result<Vec<Entry>, Report> {
|
||||
self.inventory().await
|
||||
}
|
||||
async fn hash_to(
|
||||
&mut self,
|
||||
entry: &Entry,
|
||||
output: Option<&mut ZipWriter<&mut std::fs::File>>,
|
||||
) -> Result<String, Report> {
|
||||
self.read(entry, output).await
|
||||
}
|
||||
}
|
||||
async fn write_snapshot(
|
||||
reader: &mut impl CommittedSource,
|
||||
effective: &str,
|
||||
game: &Value,
|
||||
destination: &Path,
|
||||
confirmed_closed: bool,
|
||||
) -> Result<(), Report> {
|
||||
if destination.extension().and_then(|v| v.to_str()) != Some("zip")
|
||||
|| destination.exists()
|
||||
|| destination.symlink_metadata().is_ok()
|
||||
{
|
||||
bail!("Save destination exists or is not a new ZIP")
|
||||
}
|
||||
let parent = destination
|
||||
.parent()
|
||||
.filter(|p| p.is_dir())
|
||||
.ok_or_else(|| report!("Choose an existing destination folder"))?;
|
||||
if parent
|
||||
.canonicalize()?
|
||||
.ancestors()
|
||||
.any(|p| p.join(".git").exists())
|
||||
{
|
||||
bail!("Save destination must be outside source repositories")
|
||||
}
|
||||
|
||||
let before = reader.entries().await?;
|
||||
if !before.iter().any(|e| !e.directory) {
|
||||
bail!("No committed save files found")
|
||||
}
|
||||
let unique: std::collections::BTreeSet<_> = before.iter().map(|e| e.path.as_str()).collect();
|
||||
if unique.len() != before.len() || before.len() > MAX_ENTRIES {
|
||||
bail!("Unsupported save metadata or duplicate paths")
|
||||
}
|
||||
for entry in &before {
|
||||
remote(&entry.path)?;
|
||||
}
|
||||
let total: u64 = before.iter().filter(|e| !e.directory).map(|e| e.size).sum();
|
||||
if total > MAX_BYTES {
|
||||
bail!("Save export exceeds limits")
|
||||
}
|
||||
let mut done = 0u64;
|
||||
let mut records = Vec::new();
|
||||
let mut temporary = tempfile::Builder::new()
|
||||
.prefix(".nxnative-save-export-")
|
||||
.tempfile_in(parent)?;
|
||||
let options = SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated)
|
||||
.unix_permissions(0o600);
|
||||
{
|
||||
let mut zip = ZipWriter::new(temporary.as_file_mut());
|
||||
for entry in &before {
|
||||
if entry.directory {
|
||||
zip.add_directory(entry.path.clone() + "/", options.unix_permissions(0o700))?;
|
||||
records.push(Record {
|
||||
path: entry.path.clone(),
|
||||
directory: true,
|
||||
size: 0,
|
||||
sha256: None,
|
||||
modified_ns: entry.modified.clone(),
|
||||
created_ns: entry.created.clone(),
|
||||
});
|
||||
} else {
|
||||
zip.start_file(&entry.path, options)?;
|
||||
let sha = reader.hash_to(entry, Some(&mut zip)).await?;
|
||||
done += entry.size;
|
||||
records.push(Record {
|
||||
path: entry.path.clone(),
|
||||
directory: false,
|
||||
size: entry.size,
|
||||
sha256: Some(sha),
|
||||
modified_ns: entry.modified.clone(),
|
||||
created_ns: entry.created.clone(),
|
||||
});
|
||||
super::emit(
|
||||
json!({"event":"progress","phase":"Exporting saves","fraction":if total==0 {0.5}else{done as f64/total as f64*0.5}}),
|
||||
);
|
||||
}
|
||||
}
|
||||
super::emit(json!({"event":"progress","phase":"Verifying saves"}));
|
||||
if reader.entries().await? != before {
|
||||
bail!("Save changed during export")
|
||||
}
|
||||
for (entry, record) in before.iter().zip(&records) {
|
||||
if !entry.directory && Some(reader.hash_to(entry, None).await?) != record.sha256 {
|
||||
bail!("Save changed during export")
|
||||
}
|
||||
}
|
||||
if reader.entries().await? != before {
|
||||
bail!("Save changed during export")
|
||||
}
|
||||
zip.start_file("nxnative-save-export.json", options)?;
|
||||
let manifest = json!({"schema":1,"format":"nxnative-committed-save-export","bundle_id":effective,"game_version":game["version"],"created_unix":SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(),"manual_closed_confirmation":confirmed_closed,"entries":records,"restoration":"manual; game/version/device compatibility must be checked"});
|
||||
zip.write_all(&serde_json::to_vec_pretty(&manifest)?)?;
|
||||
zip.finish()?;
|
||||
}
|
||||
publish(temporary, destination)?;
|
||||
super::emit(
|
||||
json!({"event":"saves_exported","path":destination,"files":records.iter().filter(|e|!e.directory).count(),"bytes":total}),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn save_reader_cannot_address_game_content_or_transactions() {
|
||||
for path in ["save", "save/profile/file.bin", "save_device/settings"] {
|
||||
assert!(remote(path).is_ok())
|
||||
}
|
||||
for path in [
|
||||
"romfs/file",
|
||||
"save.work/file",
|
||||
"save.new/file",
|
||||
"save_device.work/file",
|
||||
"save/../romfs",
|
||||
"/Documents/save",
|
||||
"save//file",
|
||||
"save/link\\file",
|
||||
"save/.dm-case-alias",
|
||||
"save/prod.keys",
|
||||
"save/private.p12",
|
||||
"save/./file",
|
||||
] {
|
||||
assert!(remote(path).is_err(), "{path}")
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn export_publication_never_overwrites_a_backup() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let path = root.path().join("save.zip");
|
||||
std::fs::write(&path, b"keep").unwrap();
|
||||
let mut temporary = tempfile::NamedTempFile::new_in(root.path()).unwrap();
|
||||
temporary.write_all(b"new").unwrap();
|
||||
assert!(publish(temporary, &path).is_err());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"keep");
|
||||
}
|
||||
struct Memory {
|
||||
bytes: Vec<u8>,
|
||||
reads: usize,
|
||||
change: bool,
|
||||
}
|
||||
impl CommittedSource for Memory {
|
||||
async fn entries(&mut self) -> Result<Vec<Entry>, Report> {
|
||||
Ok(vec![
|
||||
Entry {
|
||||
path: "save".into(),
|
||||
size: 0,
|
||||
directory: true,
|
||||
modified: "same".into(),
|
||||
created: "same".into(),
|
||||
},
|
||||
Entry {
|
||||
path: "save/progress.dat".into(),
|
||||
size: self.bytes.len() as u64,
|
||||
directory: false,
|
||||
modified: "same".into(),
|
||||
created: "same".into(),
|
||||
},
|
||||
])
|
||||
}
|
||||
async fn hash_to(
|
||||
&mut self,
|
||||
_entry: &Entry,
|
||||
output: Option<&mut ZipWriter<&mut std::fs::File>>,
|
||||
) -> Result<String, Report> {
|
||||
let mut bytes = self.bytes.clone();
|
||||
if self.change && self.reads > 0 {
|
||||
bytes[0] ^= 1
|
||||
};
|
||||
self.reads += 1;
|
||||
if let Some(writer) = output {
|
||||
writer.write_all(&bytes)?
|
||||
}
|
||||
Ok(hex::encode(Sha256::digest(&bytes)))
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn actual_zip_roundtrip_and_manifest_preserve_save_bytes() {
|
||||
use std::io::Read;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let path = root.path().join("saved.zip");
|
||||
let bytes = b"synthetic committed progress".to_vec();
|
||||
let mut source = Memory {
|
||||
bytes: bytes.clone(),
|
||||
reads: 0,
|
||||
change: false,
|
||||
};
|
||||
write_snapshot(
|
||||
&mut source,
|
||||
"org.synthetic.game",
|
||||
&json!({"version":"2.1.0"}),
|
||||
&path,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut archive = zip::ZipArchive::new(std::fs::File::open(&path).unwrap()).unwrap();
|
||||
let mut restored = Vec::new();
|
||||
archive
|
||||
.by_name("save/progress.dat")
|
||||
.unwrap()
|
||||
.read_to_end(&mut restored)
|
||||
.unwrap();
|
||||
assert_eq!(restored, bytes);
|
||||
let manifest: Value =
|
||||
serde_json::from_reader(archive.by_name("nxnative-save-export.json").unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
archive.by_name("save/").unwrap().unix_mode().unwrap() & 0o777,
|
||||
0o700
|
||||
);
|
||||
assert_eq!(
|
||||
manifest["entries"][1]["sha256"],
|
||||
hex::encode(Sha256::digest(&bytes))
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::metadata(path).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
assert_eq!(source.bytes, bytes);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn changing_source_never_publishes_a_partial_backup() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let path = root.path().join("saved.zip");
|
||||
let mut source = Memory {
|
||||
bytes: b"original".to_vec(),
|
||||
reads: 0,
|
||||
change: true,
|
||||
};
|
||||
assert!(
|
||||
write_snapshot(
|
||||
&mut source,
|
||||
"org.synthetic.game",
|
||||
&json!({"version":"2.1.0"}),
|
||||
&path,
|
||||
true
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(!path.exists());
|
||||
assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 0);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user