Add safe manual save export and separate device game-data updates

This commit is contained in:
Erick Sanchez committed 2026-10-27 14:04:50 -06:00
1 parent 71304491d3
commit 159c960b44
15 files changed
+856 -27

No files matched your search

+12
View File
@@ -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
+11
View File
@@ -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
+5 -2
View File
@@ -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()}}
+25
View File
@@ -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",
+111 -14
View File
@@ -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")
}
+2 -2
View File
@@ -1,5 +1,5 @@
{
"version": "1.10.1",
"build": 16,
"version": "1.11.0",
"build": 17,
"minimumMacOSMajor": 26
}
+5
View File
@@ -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.
+34
View File
@@ -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.
+29
View File
@@ -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.
+28
View File
@@ -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.
+42
View File
@@ -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.
+1 -1
View File
@@ -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> {
+77 -2
View File
@@ -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"
+447
View File
@@ -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);
}
}