diff --git a/README-es.md b/README-es.md index 82e8ad0..56ad1c6 100644 --- a/README-es.md +++ b/README-es.md @@ -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 diff --git a/README.md b/README.md index 79f04c7..83958f7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/desktop/NxNativeEasy/LibraryWorkspace.swift b/desktop/NxNativeEasy/LibraryWorkspace.swift index 280eb3c..006d14d 100644 --- a/desktop/NxNativeEasy/LibraryWorkspace.swift +++ b/desktop/NxNativeEasy/LibraryWorkspace.swift @@ -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()}} diff --git a/desktop/NxNativeEasy/Localization.swift b/desktop/NxNativeEasy/Localization.swift index 3026639..38484f6 100644 --- a/desktop/NxNativeEasy/Localization.swift +++ b/desktop/NxNativeEasy/Localization.swift @@ -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", diff --git a/desktop/NxNativeEasy/Sideloading.swift b/desktop/NxNativeEasy/Sideloading.swift index 4a83ae2..f138911 100644 --- a/desktop/NxNativeEasy/Sideloading.swift +++ b/desktop/NxNativeEasy/Sideloading.swift @@ -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=[] + 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()} diff --git a/desktop/NxNativeEasy/tests/sideload_check.swift b/desktop/NxNativeEasy/tests/sideload_check.swift index ee89240..bab7d19 100644 --- a/desktop/NxNativeEasy/tests/sideload_check.swift +++ b/desktop/NxNativeEasy/tests/sideload_check.swift @@ -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") } diff --git a/desktop/release.json b/desktop/release.json index ef89494..ac23808 100644 --- a/desktop/release.json +++ b/desktop/release.json @@ -1,5 +1,5 @@ { - "version": "1.10.1", - "build": 16, + "version": "1.11.0", + "build": 17, "minimumMacOSMajor": 26 } diff --git a/docs/agents/07-upstream-maintenance.md b/docs/agents/07-upstream-maintenance.md index d80824c..9558cec 100644 --- a/docs/agents/07-upstream-maintenance.md +++ b/docs/agents/07-upstream-maintenance.md @@ -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. diff --git a/docs/personal-ios.md b/docs/personal-ios.md index bfda24f..7c2dfdf 100644 --- a/docs/personal-ios.md +++ b/docs/personal-ios.md @@ -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. diff --git a/docs/saves-es.md b/docs/saves-es.md index a7d4d2a..d142c0e 100644 --- a/docs/saves-es.md +++ b/docs/saves-es.md @@ -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. diff --git a/docs/saves.md b/docs/saves.md index c48c989..2544fe1 100644 --- a/docs/saves.md +++ b/docs/saves.md @@ -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. diff --git a/docs/sideloading.md b/docs/sideloading.md index 6a8997b..fb0b95b 100644 --- a/docs/sideloading.md +++ b/docs/sideloading.md @@ -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. diff --git a/native/sideload-helper/src/games.rs b/native/sideload-helper/src/games.rs index 48ec79a..c13e45a 100644 --- a/native/sideload-helper/src/games.rs +++ b/native/sideload-helper/src/games.rs @@ -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, Report> { diff --git a/native/sideload-helper/src/main.rs b/native/sideload-helper/src/main.rs index e00dc50..cbf29e1 100644 --- a/native/sideload-helper/src/main.rs +++ b/native/sideload-helper/src/main.rs @@ -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 = 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" diff --git a/native/sideload-helper/src/saves.rs b/native/sideload-helper/src/saves.rs new file mode 100644 index 0000000..4f23e5b --- /dev/null +++ b/native/sideload-helper/src/saves.rs @@ -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 { + 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, 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, 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 { + 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, + 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, Report>; + async fn hash_to( + &mut self, + entry: &Entry, + output: Option<&mut ZipWriter<&mut std::fs::File>>, + ) -> Result; +} +impl CommittedSource for SaveReader { + async fn entries(&mut self) -> Result, Report> { + self.inventory().await + } + async fn hash_to( + &mut self, + entry: &Entry, + output: Option<&mut ZipWriter<&mut std::fs::File>>, + ) -> Result { + 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, + reads: usize, + change: bool, + } + impl CommittedSource for Memory { + async fn entries(&mut self) -> Result, 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 { + 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); + } +}