diff --git a/README-es.md b/README-es.md index 70360a1..b907a00 100644 --- a/README-es.md +++ b/README-es.md @@ -99,16 +99,21 @@ controles, ni los archivos JSON de preparación. [Guía detallada de partidas y ## Pon tu juego en iPhone o iPad -1. Prepara tu juego y pulsa **Instalar en iPhone / iPad**. +1. Elige iPhone/iPad, tu carpeta del juego y tus llaves. Deja IPA ligera o desactívala para incluir todo en la IPA; pulsa **Preparar juego**. El instalador se abre al terminar correctamente. 2. Conecta y desbloquea tu dispositivo. Confía en el Mac y activa Modo de desarrollador cuando iOS lo pida. 3. Inicia sesión con tu cuenta Apple, introduce el código y selecciona tu equipo personal. 4. Si ya tienes el juego instalado, elige esa app existente para conservar su identidad. Pulsa **Firmar y actualizar**; no desinstales el juego anterior. 5. Mantén cerrado el juego hasta que termine la firma, instalación y copia de datos. La renovación opcional recuerda la sesión en el Llavero. -En **Mis iPhones y iPads**, arriba en la ventana principal, ves las instalaciones -registradas y la fecha real de vencimiento de su firma. Pulsa **Renovar ahora** +En **Mis iPhones y iPads**, arriba en la ventana principal, ves los dispositivos conectados (identificador de modelo, sistema y USB/Wi-Fi), +las apps instaladas con buscador, el acceso a tu cuenta Apple y las firmas +registradas. **Actualizar con mis archivos** inicia la preparación para una app +existente. Pulsa **Renovar ahora** sin recompilar ni copiar RomFS otra vez; también puedes pausar la renovación -por juego. **Revisar cuenta e instalación** recupera su dispositivo, equipo e +por juego. **Leer dispositivos de mi cuenta Apple** consulta los registros del +equipo después de iniciar sesión; es distinto a ver dispositivos conectados. +**Actividad de instalación** muestra avance, tiempo y logs copiables/guardados +sin credenciales. **Revisar cuenta e instalación** recupera su dispositivo, equipo e identificador. **Usar sesión del Llavero** permite volver a entrar sin escribir la contraseña cuando esa sesión todavía sea válida. Quitar un registro sólo detiene su renovación; no desinstala el juego ni elimina partidas. diff --git a/README.md b/README.md index f4dc6d6..c40b497 100644 --- a/README.md +++ b/README.md @@ -93,15 +93,19 @@ RomFS and control profiles. [Save locations and backup limits](docs/saves.md). ## Put your game on iPhone or iPad -1. Prepare your game, then choose **Install on iPhone / iPad**. +1. Choose iPhone/iPad, your game folder and keys. Keep lightweight mode or turn it off for a complete IPA, then press **Prepare game**. The installer opens automatically when preparation succeeds. 2. Connect and unlock your device. Trust the Mac and enable Developer Mode when iOS asks. 3. Sign in with your own Apple account, enter the verification code and choose your personal team. 4. If the game is already installed, select that existing app to retain its identity. Choose **Sign and update**; keep the previous app installed. 5. Keep the game closed while signing, installation and game-data copying finish. Optional refresh remembers the session in Keychain. -**My iPhones and iPads**, at the top of the main window, lists our recorded -installations and actual signature expiry dates. Use **Refresh now** without +**My iPhones and iPads**, at the top of the main window, shows connected devices (model identifier, OS and USB/Wi-Fi), searchable installed +apps, your Apple-account sign-in and our recorded signature expiry dates. Choose +**Update using my files** on an installed app to begin a game update. Use **Refresh now** without recompiling or copying RomFS again, or pause automatic renewal per game. +**Read my Apple account devices** queries registrations after sign-in/team selection; +these are separate from currently connected devices. **Installation activity** shows +progress, elapsed time and copyable/retained logs without credentials. **Review account and installation** restores that game's original device/team/ID; **Use Keychain session** signs back in without entering the password again when the remembered session is still valid. Removing a record stops its renewal; diff --git a/desktop/NxNativeEasy/BuildActivity.swift b/desktop/NxNativeEasy/BuildActivity.swift index 3e5c44b..aa967a9 100644 --- a/desktop/NxNativeEasy/BuildActivity.swift +++ b/desktop/NxNativeEasy/BuildActivity.swift @@ -76,3 +76,19 @@ struct PreviousPreparation { return Self(target:target,version:record["version"] as? String ?? info?["CFBundleShortVersionString"] as? String,bundleID:record["bundle_id"] as? String ?? info?["CFBundleIdentifier"] as? String) } } + + +// Full mode is explicit: older preparation records must be rebuilt so their +// runtime understands bundled-data priority over an older Documents/romfs. +enum IOSPackageOutput { + static func load(record:[String:Any],split:Bool)->(ipa:URL,romfs:URL?)? { + if !split { + guard record["packaging"] as? String=="full",let path=record["full_ipa"] as? String,FileManager.default.isReadableFile(atPath:path) else {return nil} + return (URL(fileURLWithPath:path),nil) + } + guard let path=record["light_dir"] as? String else {return nil} + let root=URL(fileURLWithPath:path) + guard let files=try? FileManager.default.contentsOfDirectory(at:root,includingPropertiesForKeys:nil),let ipa=files.sorted(by:{$0.path<$1.path}).first(where:{$0.pathExtension.lowercased()=="ipa"}) else {return nil} + return (ipa,root.appendingPathComponent("romfs")) + } +} diff --git a/desktop/NxNativeEasy/Localization.swift b/desktop/NxNativeEasy/Localization.swift index 912e514..ee8ca4f 100644 --- a/desktop/NxNativeEasy/Localization.swift +++ b/desktop/NxNativeEasy/Localization.swift @@ -12,6 +12,55 @@ enum EasyLanguage: String, CaseIterable { struct EasyStrings { static let english: [String:String] = [ + "Mostrar más apps":"Show more apps", + "Conecta tu dispositivo e inicia sesión. Firmar e instalar requiere tu confirmación.":"Connect your device and sign in. Signing and installing require your confirmation.", + "Guarda y cierra el juego antes de instalar. Firma, copia y renovación nativas aún requieren validación completa con una cuenta y dispositivo reales.":"Save and close the game before installing. Native signing, transfer and refresh still need complete validation with a real account and device.", + + "No pudimos leer las apps. Desbloquea el dispositivo, confía en este Mac y vuelve a buscar.":"Could not read installed apps. Unlock the device, trust this Mac and search again.", + "No pudimos buscar dispositivos. Revisa la conexión y vuelve a intentar.":"Could not search for devices. Check the connection and try again.", + "Apple no devolvió los dispositivos de este equipo. Revisa tu sesión y vuelve a consultar.":"Apple did not return this team’s devices. Check your session and try again.", + "Tipo de equipo según Apple":"Team type reported by Apple", + + "IPA ligera: separar los datos del juego":"Lightweight IPA: separate game data", + "Recomendado: la app pesa menos y el instalador copia romfs por separado.":"Recommended: a smaller app, with romfs copied separately by the installer.", + "IPA completa: incluye el juego dentro. Ocupa más y cada renovación vuelve a transferir toda la app.":"Complete IPA: includes the game inside. Uses more space and transfers the entire app on every refresh.", + "Carpeta y llaves → Preparar → Cuenta Apple → Instalar y copiar datos":"Folder and keys → Prepare → Apple account → Install and copy data", + "Consultando dispositivos registrados en Apple…":"Reading devices registered with Apple…", + "Buscando dispositivos conectados…":"Looking for connected devices…", + "Leyendo apps instaladas…":"Reading installed apps…", + "Búsqueda de dispositivos terminada.":"Device search finished.", + "Lista de apps instalada actualizada.":"Installed app list updated.", + "Dispositivos registrados consultados.":"Registered devices retrieved.", + "La operación se detuvo. Revisa el mensaje de error.":"The operation stopped. Review the error message.", + "Operación cancelada; puedes volver a intentar.":"Operation cancelled; you can try again.", + "Registrando el dispositivo con Apple…":"Registering the device with Apple…", + "Verificando firma y vencimiento…":"Verifying signature and expiry…", + "Conecta. Elige tu juego. Instala o actualiza.":"Connect. Choose your game. Install or update.", + "Dispositivos conectados":"Connected devices", + "Información del dispositivo no disponible":"Device information unavailable", + "Desbloquea y confía en este Mac para leer las apps.":"Unlock and trust this Mac to read installed apps.", + "Tu cuenta Apple":"Your Apple account", + "Inicia sesión aquí para firmar e instalar.":"Sign in here to sign and install.", + "Ver cuenta y equipos":"View account and teams", + "Instalar otro juego":"Install another game", + "Elige tus archivos; prepararemos el juego y abriremos el instalador al terminar.":"Choose your files; we will prepare the game and open the installer when ready.", + "Elegir carpeta de juego":"Choose game folder", + "Apps instaladas en este dispositivo":"Apps installed on this device", + "Buscar por nombre o identificador":"Search by name or identifier", + "No se leyeron apps. Desbloquea el dispositivo y vuelve a buscar.":"No apps were retrieved. Unlock the device and search again.", + "Actualizar con mis archivos":"Update using my files", + "Actualizar requiere tus archivos del mismo juego y la cuenta/equipo originales. No desinstalamos la app existente.":"Updating needs your files for the same game and the original account/team. The existing app is kept installed.", + "Firmas y renovación":"Signatures and refresh", + "Actividad de instalación":"Installation activity", + "Copiar registro":"Copy activity log", + "Mostrar registro guardado":"Show saved activity log", + "1 · Dispositivo":"1 · Device", + "2 · Cuenta Apple":"2 · Apple account", + "3 · Instalar":"3 · Install", + "Leer dispositivos de mi cuenta Apple":"Read my Apple account devices", + "Dispositivos registrados en el equipo":"Devices registered with the team", + "Cuenta gratuita: Apple indica hasta 3 dispositivos y perfiles de 7 días. Esta lista muestra registros reales; no calcula cupos libres ni elimina dispositivos.":"Free account: Apple documents up to 3 devices and 7-day profiles. This list shows actual registrations; it does not calculate available slots or remove devices.", + "Mis iPhones y iPads":"My iPhones and iPads", "Usar sesión del Llavero":"Use Keychain session", "Inicia sesión con la cuenta que instaló este juego para renovarlo.":"Sign in with the account that installed this game to refresh it.", diff --git a/desktop/NxNativeEasy/Sideloading.swift b/desktop/NxNativeEasy/Sideloading.swift index 01c79a3..bc4f287 100644 --- a/desktop/NxNativeEasy/Sideloading.swift +++ b/desktop/NxNativeEasy/Sideloading.swift @@ -5,10 +5,25 @@ import CryptoKit import ServiceManagement import Darwin -struct SideloadDevice:Identifiable {let id:String;let name:String} +struct SideloadDevice:Identifiable { + let id:String;let name:String + var model:String="";var os:String="";var kind:String="";var ready:Bool=true;var connections:[String]=[] + var detail:String {[model,os.isEmpty ? "" : (kind=="iPad" ? "iPadOS " : "iOS ")+os,connections.joined(separator:" + ")].filter{!$0.isEmpty}.joined(separator:" · ")} + static func decode(_ items:[[String:Any]])->[SideloadDevice] { + var result:[String:SideloadDevice]=[:] + for item in items { + guard let id=item["udid"] as? String,!id.isEmpty,let name=item["name"] as? String else {continue} + let value=SideloadDevice(id:id,name:name,model:item["model"] as? String ?? "",os:item["os"] as? String ?? "",kind:item["kind"] as? String ?? "",ready:item["ready"] as? Bool ?? true,connections:item["connections"] as? [String] ?? []) + if result[id]==nil || value.connections.contains("USB") {result[id]=value} + } + return result.values.sorted {$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending} + } +} +struct AccountDevice:Identifiable {let id:String;let name:String;let status:String} +struct InstallLog:Identifiable {let id=UUID();let date=Date();let message:String;var progress:Double?=nil} struct TrustedSMS:Identifiable {let id:Int;let digits:String} -struct SideloadTeam:Identifiable {let id:String;let name:String} -struct InstalledGame:Identifiable {let id:String;let name:String} +struct SideloadTeam:Identifiable {let id:String;let name:String;var kind:String=""} +struct InstalledGame:Identifiable {let id:String;let name:String;var version:String=""} struct RefreshJob:Codable,Identifiable { var id:String var ipa:String @@ -38,6 +53,13 @@ enum RefreshPolicy { @Published var devices:[SideloadDevice]=[] @Published var teams:[SideloadTeam]=[] @Published var apps:[InstalledGame]=[] + @Published var accountDevices:[AccountDevice]=[] + @Published var accountDeviceTeam="" + @Published var logs:[InstallLog]=[] + @Published var logURL:URL? + private var pendingUpdate:(device:String,id:String)? + private var loggedPhase="" + private var loggedBucket = -1 @Published var device="" @Published var team="" @Published var existing="" @@ -82,13 +104,43 @@ enum RefreshPolicy { func configure(ipa:URL,folder:URL,romfs:URL?) { self.ipa=ipa;gameFolder=folder;self.romfs=romfs originalID=PreviousPreparation.load(folder:folder,target:"ios")?.bundleID ?? "" - existing="" - if let known=jobs.first(where:{$0.originalID==originalID && $0.gameFolder==folder.path && $0.account==RefreshPolicy.account(email)}) { + existing=pendingUpdate?.id ?? "" + if let pendingUpdate {device=pendingUpdate.device;self.pendingUpdate=nil} + if existing.isEmpty,let known=jobs.first(where:{$0.originalID==originalID && $0.gameFolder==folder.path && $0.account==RefreshPolicy.account(email)}) { if device.isEmpty {device=known.udid};if team.isEmpty {team=known.team} if device==known.udid {existing=known.effectiveID} } connect();listDevices() } + func cancelPendingUpdate() {pendingUpdate=nil} + func prepareUpdate(_ app:InstalledGame) {guard !busy,!device.isEmpty else {return};pendingUpdate=(device,app.id)} + func openDevice(_ id:String) { + guard !busy else {return} + device=id;existing="";apps=[];inspectApps() + } + func inspectAccountDevices() { + guard !busy,authenticated,!team.isEmpty else {return} + busy=true;error=nil;startedAt=Date();fraction=nil;phase="Consultando dispositivos registrados en Apple…";accountDevices=[];accountDeviceTeam="";record(phase) + send(["op":"account_devices","team":team]) + } + func copyLogs(text:(String)->String) { + let output=logs.map {$0.date.formatted(date:.omitted,time:.standard)+" · "+text($0.message)+($0.progress.map{" · "+String(Int($0*100))+" %"} ?? "")}.joined(separator:"\n") + NSPasteboard.general.clearContents();NSPasteboard.general.setString(output,forType:.string) + } + private func record(_ message:String,progress:Double?=nil) { + // Only local fixed messages enter this log. Never serialize helper events, + // Apple responses, identifiers, account names, passwords or tokens. + if logs.count>=400 {logs.removeFirst(logs.count-399)} + logs.append(InstallLog(message:message,progress:progress)) + if logURL==nil,let folder=gameFolder { + let root=folder.appendingPathComponent("nxnative-tools/logs") + do {try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true,attributes:[.posixPermissions:0o700]);logURL=root.appendingPathComponent("install-"+UUID().uuidString+".log")}catch{} + } + if let logURL { + let output=logs.map {$0.date.ISO8601Format()+" · "+$0.message+($0.progress.map{" · "+String(Int($0*100))+" %"} ?? "")}.joined(separator:"\n")+"\n" + do {try Data(output.utf8).write(to:logURL,options:.atomic);try FileManager.default.setAttributes([.posixPermissions:0o600],ofItemAtPath:logURL.path)}catch{} + } + } func connect() { if let process,process.isRunning {return} process=nil;input=nil @@ -112,12 +164,13 @@ enum RefreshPolicy { do {var data=try JSONSerialization.data(withJSONObject:value);data.append(10);try input.write(contentsOf:data)} catch {self.error="No pudimos comunicarnos con el instalador.";busy=false} } - func listDevices() {guard !busy else {return};busy=true;send(["op":"devices"])} - func inspectApps() {guard !busy,!device.isEmpty else {return};busy=true;send(["op":"installed_apps","udid":device])} + func listDevices() {guard !busy else {return};error=nil;busy=true;startedAt=Date();fraction=nil;phase="Buscando dispositivos conectados…";record(phase);send(["op":"devices"])} + func inspectApps() {guard !busy,!device.isEmpty else {return};busy=true;startedAt=Date();fraction=nil;phase="Leyendo apps instaladas…";record(phase);send(["op":"installed_apps","udid":device])} func signIn(password:String,remember:Bool) { guard !busy,!email.isEmpty,!password.isEmpty else {return} email=email.trimmingCharacters(in:.whitespacesAndNewlines).lowercased() - busy=true;error=nil;startedAt=Date();phase="Iniciando sesión con Apple…" + busy=true;error=nil;startedAt=Date();fraction=nil;phase="Iniciando sesión con Apple…" + record("Iniciando sesión con Apple…") pendingRemember=remember send(["op":"login","email":email,"password":password,"remember":remember,"anisette_url":anisette]) } @@ -126,7 +179,7 @@ enum RefreshPolicy { func sendSMS(_ number:Int) {codeRequired=false;send(["op":"sms","number":number])} 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() {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 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) { guard !busy,authenticated,let ipa,let gameFolder,!originalID.isEmpty,!device.isEmpty,!team.isEmpty else {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} @@ -137,7 +190,7 @@ enum RefreshPolicy { private func startInstall(_ job:RefreshJob,updating:Bool,content:URL?) { 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;busy=true;error=nil;startedAt=Date();phase="Firmando e instalando…";fraction=nil + 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 updating {value["expected_id"]=job.effectiveID} if !job.sha256.isEmpty {value["sha256"]=job.sha256} @@ -155,7 +208,7 @@ enum RefreshPolicy { switch event["event"] as? String { case "hello":if (event["protocol"] as? Int) != 1 || event["architecture"] as? String != "aarch64" {error="El instalador debe compilarse para Apple silicon.";cancel()} case "devices": - devices=((event["items"] as? [[String:Any]]) ?? []).compactMap {item in guard let id=item["udid"] as? String,let name=item["name"] as? String else {return nil};return SideloadDevice(id:id,name:name)} + devices=SideloadDevice.decode((event["items"] as? [[String:Any]]) ?? []);record("Búsqueda de dispositivos terminada.") busy=false if let refresh=activeRefresh {continueRefresh(refresh)} else { @@ -164,34 +217,46 @@ enum RefreshPolicy { if !device.isEmpty {inspectApps()} } case "installed_apps": - apps=((event["items"] as? [[String:Any]]) ?? []).compactMap {item in guard let id=item["id"] as? String,let name=item["name"] as? String else {return nil};return InstalledGame(id:id,name:name)}.sorted {$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending} + apps=((event["items"] as? [[String:Any]]) ?? []).compactMap {item in guard let id=item["id"] as? String,let name=item["name"] as? String else {return nil};return InstalledGame(id:id,name:name,version:item["version"] as? String ?? "")}.sorted {$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending} + record("Lista de apps instalada actualizada.") let matches=apps.filter {$0.id.hasPrefix(originalID)};if existing.isEmpty,matches.count==1 {existing=matches[0].id} busy=false - case "two_factor":codeRequired=true;phase="Introduce el código de Apple.";smsNumbers=((event["numbers"] as? [[String:Any]]) ?? []).compactMap {number in guard let id=number["id"] as? Int,let digits=number["last_digits"] as? String else {return nil};return TrustedSMS(id:id,digits:digits)} + case "account_devices": + accountDeviceTeam=event["team"] as? String ?? "" + accountDevices=((event["items"] as? [[String:Any]]) ?? []).compactMap {item in guard let id=item["udid"] as? String else {return nil};return AccountDevice(id:id,name:item["name"] as? String ?? String(id.suffix(8)),status:item["status"] as? String ?? "")} + busy=false;record("Dispositivos registrados consultados.") + case "two_factor":record("Introduce el código de Apple.");codeRequired=true;phase="Introduce el código de Apple.";smsNumbers=((event["numbers"] as? [[String:Any]]) ?? []).compactMap {number in guard let id=number["id"] as? Int,let digits=number["last_digits"] as? String else {return nil};return TrustedSMS(id:id,digits:digits)} case "signed_in": - authenticated=true;busy=false;codeRequired=false;error=nil;phase="Sesión iniciada. Elige tu equipo personal." + record("Sesión iniciada. Elige tu equipo personal.");authenticated=true;busy=false;codeRequired=false;error=nil;phase="Sesión iniciada. Elige tu equipo personal." if let remember=pendingRemember {if remember {defaults.set(email,forKey:"sideloadEmail");defaults.set(anisette,forKey:"sideloadAnisette")}else{defaults.removeObject(forKey:"sideloadEmail")};pendingRemember=nil} - teams=((event["teams"] as? [[String:Any]]) ?? []).compactMap {item in guard let id=item["id"] as? String else {return nil};return SideloadTeam(id:id,name:item["name"] as? String ?? id)} + teams=((event["teams"] as? [[String:Any]]) ?? []).compactMap {item in guard let id=item["id"] as? String else {return nil};return SideloadTeam(id:id,name:item["name"] as? String ?? id,kind:item["kind"] as? String ?? "")} if !teams.contains(where:{$0.id==team}) {team=""} for index in jobs.indices where jobs[index].account==RefreshPolicy.account(email) {jobs[index].needsLogin=false};saveJobs() if let refresh=activeRefresh {refreshAuthenticated=true;continueRefresh(refresh)} - case "signed_out":authenticated=false;teams=[];team="";busy=false + case "signed_out":authenticated=false;accountDevices=[];accountDeviceTeam="";teams=[];team="";busy=false case "progress": - let phases=["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=["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 "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 } job.expires=Date(timeIntervalSince1970:expiry);job.sha256=hash;job.retryAfter=nil;job.needsLogin=false;jobs.removeAll {$0.id==job.id};jobs.append(job);error=nil;saveJobs();existing=actual - installIntent=nil;activeRefresh=nil;refreshAuthenticated=false;busy=false;fraction=1;phase="Instalado. Tus partidas se conservan en el dispositivo." + installIntent=nil;activeRefresh=nil;refreshAuthenticated=false;busy=false;fraction=1;phase="Instalado. Tus partidas se conservan en el dispositivo.";record(phase) 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."] 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=="installed_apps" {apps=[];error="No pudimos leer las apps. 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."} if let code=event["apple_code"] as? Int {error=(error ?? "")+" · Apple: \(code)"} if op=="login" || op=="restore" {authenticated=false;pendingRemember=nil} if let refresh=activeRefresh,let index=jobs.firstIndex(where:{$0.id==refresh.id}) {jobs[index].retryAfter=Date().addingTimeInterval((event["code"] as? String)=="game_running" ? 600 : 6*3600);jobs[index].needsLogin=(op=="restore");saveJobs()} + if let error {record(error)} activeRefresh=nil;installIntent=nil;refreshAuthenticated=false;codeRequired=false;busy=false default:break } @@ -207,7 +272,7 @@ enum RefreshPolicy { } func restoreSession() { guard !busy,!email.isEmpty,defaults.string(forKey:"sideloadEmail")==email else {return} - busy=true;error=nil;startedAt=Date();phase="Renovando sesión guardada…" + busy=true;error=nil;startedAt=Date();fraction=nil;phase="Renovando sesión guardada…";record(phase) send(["op":"restore","email":email,"anisette_url":anisette]) } var hasRememberedSession:Bool {defaults.string(forKey:"sideloadEmail")==email && !email.isEmpty} @@ -266,32 +331,48 @@ struct SideloadView:View { var body:some View { ScrollView { VStack(alignment:.leading,spacing:16) { - HStack {Text(text("Instalar en iPhone / iPad")).font(.title2.bold());Spacer();Button(text("Cerrar")){dismiss()}.disabled(model.busy)} + HStack {Text(text(model.ipa==nil ? "Tu cuenta Apple" : "Instalar en iPhone / iPad")).font(.title2.bold());Spacer();Button(text("Cerrar")){dismiss()}.disabled(model.busy)} + HStack(spacing:18) { + Label(text("1 · Dispositivo"),systemImage:model.device.isEmpty ? "circle" : "checkmark.circle.fill") + Label(text("2 · Cuenta Apple"),systemImage:model.authenticated ? "checkmark.circle.fill" : "circle") + Label(text("3 · Instalar"),systemImage:"arrow.down.app") + }.font(.caption).foregroundStyle(.secondary) if let folder=model.gameFolder {Text(folder.lastPathComponent).font(.headline)} - Text(text("Instalación nativa en Apple silicon. Cierra el juego y no lo abras hasta terminar. La firma e instalación con tu cuenta aún necesitan validación en un dispositivo.")) + Text(text("Conecta tu dispositivo e inicia sesión. Firmar e instalar requiere tu confirmación.")).foregroundStyle(.secondary) Picker(text("Dispositivo"),selection:$model.device) {Text(text("Elige tu iPhone/iPad")).tag("");ForEach(model.devices){Text($0.name).tag($0.id)}}.disabled(model.busy) .onChange(of:model.device) {_,_ in model.existing="";model.inspectApps()} + if let selected=model.devices.first(where:{$0.id==model.device}),!selected.detail.isEmpty {Text(selected.detail).font(.caption).foregroundStyle(.secondary)} Button(text("Buscar dispositivos")){model.listDevices()}.disabled(model.busy) Text(text("Desbloquea el dispositivo, confía en este Mac y activa Modo de desarrollador si iOS lo pide.")) .font(.caption).foregroundStyle(.secondary) if !model.authenticated { - TextField(text("Cuenta Apple personal"),text:$model.email).disabled(model.busy) - SecureField(text("Contraseña de Apple"),text:$password).disabled(model.busy) + LabeledContent(text("Cuenta Apple personal")){TextField("name@example.com",text:$model.email).textFieldStyle(.roundedBorder).disabled(model.busy)} + LabeledContent(text("Contraseña de Apple")){SecureField(text("Contraseña de Apple"),text:$password).textFieldStyle(.roundedBorder).disabled(model.busy)} Toggle(text("Recordar sesión en el Llavero; no guardar la contraseña"),isOn:$remember).disabled(model.busy).onChange(of:remember){_,value in if !value {autoRefresh=false}} DisclosureGroup(text("Servicio de autenticación")) { Text(text("La contraseña y el código se envían a Apple. El servicio anisette recibe los datos técnicos que requiere la autenticación, no tu contraseña.")) .font(.caption) TextField("Anisette HTTPS",text:$model.anisette).disabled(model.busy) } if model.hasRememberedSession {Button(text("Usar sesión del Llavero")){model.restoreSession()}.disabled(model.busy)} - Button(text("Iniciar sesión")){model.signIn(password:password,remember:remember);password=""}.disabled(model.busy || model.email.isEmpty || password.isEmpty) + Button(text("Iniciar sesión")){model.signIn(password:password,remember:remember);password=""}.buttonStyle(.borderedProminent).disabled(model.busy || model.email.isEmpty || password.isEmpty) } else { Label(text("Sesión iniciada"),systemImage:"person.crop.circle.badge.checkmark") + Text(model.email).font(.caption).textSelection(.enabled) Picker(text("Tu equipo personal"),selection:$model.team) {Text(text("Selecciona tu equipo")).tag("");ForEach(model.teams){Text($0.name+" · "+$0.id).tag($0.id)}}.disabled(model.busy) + if let selected=model.teams.first(where:{$0.id==model.team}),!selected.kind.isEmpty {Text(text("Tipo de equipo según Apple")+": "+selected.kind).font(.caption).foregroundStyle(.secondary)} + Button(text("Leer dispositivos de mi cuenta Apple")){model.inspectAccountDevices()}.disabled(model.busy || model.team.isEmpty) + if model.accountDeviceTeam==model.team { + Text(text("Dispositivos registrados en el equipo")+": "+String(model.accountDevices.count)).font(.headline) + ForEach(model.accountDevices) {item in Text(item.name+" · …"+String(item.id.suffix(8))+" · "+item.status).font(.caption)} + } + Text(text("Cuenta gratuita: Apple indica hasta 3 dispositivos y perfiles de 7 días. Esta lista muestra registros reales; no calcula cupos libres ni elimina dispositivos.")).font(.caption).foregroundStyle(.secondary) + if model.ipa != nil { Picker(text("App existente que actualizarás"),selection:$model.existing) {Text(text("Primera instalación")).tag("");ForEach(model.apps){Text($0.name+" · "+$0.id).tag($0.id)}}.disabled(model.busy) Text(text("Se conserva el identificador efectivo. Si no corresponde al juego y equipo, la instalación se detiene; no se desinstala la app anterior.")) .font(.caption).foregroundStyle(.secondary) Toggle(text("Renovar automáticamente antes de que caduque"),isOn:$autoRefresh).disabled(model.busy || !remember) Toggle(text("Abrir nxnative Easy al iniciar sesión"),isOn:$loginItem).onChange(of:loginItem){_,value in model.setLoginItem(value)}.disabled(model.busy) 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) + } Button(text("Cerrar sesión")){model.signOut()}.disabled(model.busy) } if model.codeRequired { @@ -300,11 +381,11 @@ struct SideloadView:View { Button(text("Enviar a mis dispositivos")){model.sendToDevices()} ForEach(model.smsNumbers) {number in Button("SMS · …"+number.digits){model.sendSMS(number.id)}} } - if model.busy {TimelineView(.periodic(from:.now,by:1)){context in Text(text("Tiempo transcurrido")+" "+String(Int(context.date.timeIntervalSince(model.startedAt ?? context.date)))+" s").font(.caption)};if let fraction=model.fraction {ProgressView(value:fraction)}else{ProgressView().progressViewStyle(.linear)};Button(text("Cancelar")){model.cancel()}} - Text(text(model.phase)).font(.callout) - if let error=model.error {Text(text(error)).foregroundStyle(.orange).textSelection(.enabled)} + Text(text("Guarda y cierra el juego antes de instalar. Firma, copia y renovación nativas aún requieren validación completa con una cuenta y dispositivo reales.")).font(.caption).foregroundStyle(.secondary) + InstallActivityView(model:model,text:text) + if !model.busy {Text(text(model.phase)).font(.callout)} }.padding(24) - }.frame(width:720,height:690).interactiveDismissDisabled(model.busy) + }.frame(width:760,height:720).tint(Color(red:0.79,green:0.95,blue:0.37)).preferredColorScheme(.dark).interactiveDismissDisabled(model.busy) } } @@ -313,51 +394,91 @@ struct SideloadLibraryView:View { @ObservedObject var model:SideloadModel let text:(String)->String let canInstall:Bool + var prepareGame:()->Void={} @Environment(\.dismiss) private var dismiss @State private var showInstaller=false @State private var forgotten:RefreshJob? + @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) var body:some View { - ScrollView { - VStack(alignment:.leading,spacing:18) { - HStack {Text(text("Mis iPhones y iPads")).font(.title2.bold());Spacer();Button(text("Cerrar")){dismiss()}.disabled(model.busy)} - Text(text("Administra los juegos instalados desde esta app. Renovar la firma no recompila el juego ni copia sus datos otra vez.")) - .font(.callout).foregroundStyle(.secondary) - HStack { - Label(text(model.devices.isEmpty ? "Conecta tu iPhone o iPad" : "Dispositivos disponibles"),systemImage:"iphone.and.ipad") - Spacer();Button(text("Buscar dispositivos")){model.listDevices()}.disabled(model.busy || !canInstall) + VStack(spacing:0) { + HStack(alignment:.top) { + VStack(alignment:.leading,spacing:6) { + Label(text("Mis iPhones y iPads"),systemImage:"iphone.and.ipad").font(.title2.bold()) + Text(text("Conecta. Elige tu juego. Instala o actualiza.")).foregroundStyle(.secondary) } - ForEach(model.devices) {device in Label(device.name,systemImage:"checkmark.circle").font(.callout)} - if model.jobs.isEmpty { - Text(text("Aún no hay juegos registrados. Prepara tu juego y pulsa Instalar en iPhone / iPad. Para una app de Sideloadly, elige su instalación existente y conserva su identidad.")) - .foregroundStyle(.secondary) - } - ForEach(model.jobs.sorted {$0.expires<$1.expires}) {job in - VStack(alignment:.leading,spacing:10) { - Text(URL(fileURLWithPath:job.ipa).deletingPathExtension().lastPathComponent).font(.headline) - Text(job.effectiveID).font(.caption).textSelection(.enabled) - HStack { - Text(text("Dispositivo")+": "+(model.devices.first(where:{$0.id==job.udid})?.name ?? String(job.udid.suffix(8)))) - Spacer() - Text(text(job.expires<=Date() ? "Firma vencida" : "La firma vence")+" "+job.expires.formatted(date:.abbreviated,time:.shortened)) - }.font(.caption).foregroundStyle(job.expires<=Date() ? .orange : .secondary) - 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)}.buttonStyle(.borderedProminent) - Button(text("Revisar cuenta e instalación")){model.select(job);showInstaller=true} - Spacer();Button(text("Quitar de esta lista")){forgotten=job} - } - }.padding(16).background(.white.opacity(0.045),in:RoundedRectangle(cornerRadius:12)).disabled(model.busy || !canInstall) - } - Text(text("Guarda y cierra el juego antes de renovar manualmente. La renovación automática requiere este Mac despierto, la app abierta, Internet y el dispositivo accesible.")) - .font(.caption).foregroundStyle(.secondary) - if model.busy { - if let fraction=model.fraction {ProgressView(value:fraction)}else{ProgressView().progressViewStyle(.linear)} - Text(text(model.phase));Button(text("Cancelar")){model.cancel()} - } - if let error=model.error {Text(text(error)).foregroundStyle(.orange).textSelection(.enabled)} + Spacer();Button(text("Cerrar")){dismiss()}.disabled(model.busy) }.padding(24) - }.frame(width:760,height:650).interactiveDismissDisabled(model.busy) + Divider() + ScrollView { + VStack(alignment:.leading,spacing:20) { + HStack { + Label(text("Dispositivos conectados"),systemImage:"cable.connector").font(.headline) + Spacer();Button(text("Buscar dispositivos")){model.listDevices()}.disabled(model.busy || !canInstall) + } + if model.devices.isEmpty && !model.busy { + VStack(alignment:.leading,spacing:10) { + Text(text("Conecta tu iPhone o iPad")).font(.headline) + Text(text("Desbloquea el dispositivo, confía en este Mac y activa Modo de desarrollador si iOS lo pide.")).foregroundStyle(.secondary) + Button(text("Buscar dispositivos")){model.listDevices()} + }.padding(18).frame(maxWidth:.infinity,minHeight:136,alignment:.topLeading).background(.white.opacity(0.04),in:RoundedRectangle(cornerRadius:14)) + } + ForEach(model.devices) {device in + Button {model.openDevice(device.id)} label:{ + HStack(spacing:14) { + Image(systemName:device.kind=="iPad" ? "ipad" : "iphone").font(.system(size:30)).foregroundStyle(accent).frame(width:42) + VStack(alignment:.leading,spacing:5) {Text(device.name).font(.headline);Text(device.detail.isEmpty ? text("Información del dispositivo no disponible") : device.detail).font(.caption).foregroundStyle(.secondary);if !device.ready {Text(text("Desbloquea y confía en este Mac para leer las apps.")).font(.caption).foregroundStyle(.orange)}} + Spacer();Image(systemName:model.device==device.id ? "checkmark.circle.fill" : "circle").foregroundStyle(model.device==device.id ? accent : .secondary) + }.padding(16).background(model.device==device.id ? accent.opacity(0.08) : .white.opacity(0.035),in:RoundedRectangle(cornerRadius:14)) + }.buttonStyle(.plain).disabled(model.busy || !canInstall) + } + HStack(alignment:.top,spacing:16) { + VStack(alignment:.leading,spacing:10) { + Label(text("Tu cuenta Apple"),systemImage:model.authenticated ? "person.crop.circle.badge.checkmark" : "person.crop.circle").font(.headline) + Text(text(model.authenticated ? "Sesión iniciada" : "Inicia sesión aquí para firmar e instalar.")).foregroundStyle(.secondary) + Button(text(model.authenticated ? "Ver cuenta y equipos" : "Iniciar sesión")){showInstaller=true}.disabled(model.busy || !canInstall) + }.padding(18).frame(maxWidth:.infinity,minHeight:136,alignment:.topLeading).background(.white.opacity(0.04),in:RoundedRectangle(cornerRadius:14)) + VStack(alignment:.leading,spacing:10) { + Label(text("Instalar otro juego"),systemImage:"plus.app").font(.headline) + Text(text("Elige tus archivos; prepararemos el juego y abriremos el instalador al terminar.")).foregroundStyle(.secondary) + Button(text("Elegir carpeta de juego")){model.cancelPendingUpdate();prepareGame();dismiss()}.disabled(model.busy || !canInstall) + }.padding(18).frame(maxWidth:.infinity,minHeight:136,alignment:.topLeading).background(.white.opacity(0.04),in:RoundedRectangle(cornerRadius:14)) + } + if !model.device.isEmpty { + HStack {Label(text("Apps instaladas en este dispositivo"),systemImage:"square.grid.2x2").font(.headline);Spacer();Text(String(model.apps.count)).foregroundStyle(.secondary)} + TextField(text("Buscar por nombre o identificador"),text:$search).textFieldStyle(.roundedBorder) + if model.apps.isEmpty && !model.busy {Text(text("No se leyeron apps. Desbloquea el dispositivo y vuelve a buscar.")).foregroundStyle(.secondary)} + ForEach(Array(matchingApps.prefix(appLimit))) {app in + HStack { + 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);prepareGame();dismiss()}.disabled(model.busy || !canInstall) + }.padding(.vertical,6) + Divider() + } + if matchingApps.count>appLimit {Button(text("Mostrar más apps")+" · "+String(min(appLimit,matchingApps.count))+" / "+String(matchingApps.count)){appLimit+=8}} + Text(text("Actualizar requiere tus archivos del mismo juego y la cuenta/equipo originales. No desinstalamos la app existente.")).font(.caption).foregroundStyle(.secondary) + } + if !model.jobs.isEmpty { + Label(text("Firmas y renovación"),systemImage:"arrow.clockwise.circle").font(.headline) + ForEach(model.jobs.sorted {$0.expires<$1.expires}) {job in + VStack(alignment:.leading,spacing:10) { + Text(URL(fileURLWithPath:job.ipa).deletingPathExtension().lastPathComponent).font(.headline) + Text(job.effectiveID).font(.caption).textSelection(.enabled) + 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}} + }.padding(16).background(.white.opacity(0.04),in:RoundedRectangle(cornerRadius:14)).disabled(model.busy || !canInstall) + } + } + InstallActivityView(model:model,text:text) + }.padding(24) + } + }.frame(width:850,height:730).background(Color(red:0.065,green:0.075,blue:0.07)).preferredColorScheme(.dark).interactiveDismissDisabled(model.busy) + .onChange(of:search){_,_ in appLimit=8} + .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}})) { @@ -366,3 +487,25 @@ struct SideloadLibraryView:View { } 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."))} } } + +struct InstallActivityView:View { + @ObservedObject var model:SideloadModel + let text:(String)->String + @State private var expanded=true + var body:some View { + VStack(alignment:.leading,spacing:10) { + if model.busy { + HStack {Text(text(model.phase)).font(.headline);Spacer();Button(text("Cancelar")){model.cancel()}} + if let fraction=model.fraction {ProgressView(value:fraction);Text(String(Int(fraction*100))+" %").font(.caption.monospacedDigit())}else{ProgressView().progressViewStyle(.linear)} + 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 !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()} + ScrollView {LazyVStack(alignment:.leading,spacing:5) {ForEach(model.logs) {entry in Text(entry.date.formatted(date:.omitted,time:.standard)+" · "+text(entry.message)+(entry.progress.map{" · "+String(Int($0*100))+" %"} ?? "")).font(.system(size:11,design:.monospaced)).textSelection(.enabled)}}.frame(maxWidth:.infinity,alignment:.leading)}.frame(height:160) + } + } + } + } +} diff --git a/desktop/NxNativeEasy/main.swift b/desktop/NxNativeEasy/main.swift index 2a20c0c..2d17c3f 100644 --- a/desktop/NxNativeEasy/main.swift +++ b/desktop/NxNativeEasy/main.swift @@ -12,6 +12,7 @@ import Darwin @Published var target = "" { didSet { output=nil;assets=nil;failed=false;restoreOutput() } } + @Published var splitIOS=(UserDefaults.standard.object(forKey:"iosSplitGameData") as? Bool ?? true) {didSet {UserDefaults.standard.set(splitIOS,forKey:"iosSplitGameData");output=nil;assets=nil;restoreOutput()}} var isMac: Bool {target == "mac"} @Published var folder: URL? @Published var keysFolder: URL? @@ -33,6 +34,8 @@ import Darwin @Published var showSaves = false @Published var showSideload = false @Published var showDeviceLibrary = false + @Published var installerReady = false + var chooseGameAfterDeviceLibrary=false @Published var catalog: [CatalogGame] = [] @Published var catalogFailed = false private var process: Process? @@ -59,25 +62,25 @@ import Darwin refreshCatalog() showCatalog=true } - func chooseFolder() { + @discardableResult func chooseFolder()->Bool { let panel=NSOpenPanel();panel.canChooseDirectories=true;panel.canChooseFiles=false panel.allowsMultipleSelection=false;panel.prompt=text("Elegir juego") panel.message=text("Selecciona la carpeta con el juego base y sus actualizaciones NSP/XCI.") - guard panel.runModal() == .OK, let url=panel.url else { return } + guard panel.runModal() == .OK, let url=panel.url else { return false } do { let files=try FileManager.default.contentsOfDirectory(at:url,includingPropertiesForKeys:nil) guard files.contains(where:{["nsp","xci","nsz","xcz"].contains($0.pathExtension.lowercased())}) else { output=nil;assets=nil;phase="No encontramos el juego en esa carpeta." detail="Elige la carpeta que contiene los archivos NSP o XCI directamente." - failed=true;return + failed=true;return false } guard !url.standardizedFileURL.path.hasPrefix(repo.standardizedFileURL.path+"/"), url.standardizedFileURL != repo.standardizedFileURL else { - phase="Guarda el juego fuera del repositorio.";detail="Así tus juegos y llaves se quedan fuera de GitHub.";failed=true;return + phase="Guarda el juego fuera del repositorio.";detail="Así tus juegos y llaves se quedan fuera de GitHub.";failed=true;return false } folder=url;UserDefaults.standard.set(url.path,forKey:"lastGameFolder") phase="Listo para preparar el juego.";detail="Un clic más. Los archivos anteriores se conservan." - failed=false;output=nil;assets=nil;transcript="";restoreOutput() - } catch { phase="No pudimos leer esa carpeta.";detail=error.localizedDescription;failed=true } + failed=false;output=nil;assets=nil;transcript="";restoreOutput();return true + } catch { phase="No pudimos leer esa carpeta.";detail=error.localizedDescription;failed=true;return false } } func chooseKeysFolder() { let panel=NSOpenPanel();panel.canChooseDirectories=true;panel.canChooseFiles=false @@ -107,11 +110,10 @@ import Darwin guard target == "ios", let folder, let data=try? Data(contentsOf:folder.appendingPathComponent("nxnative-ios.json")), let record=(try? JSONSerialization.jsonObject(with:data)) as? [String:Any], - let path=record["light_dir"] as? String else {return} - let directory=URL(fileURLWithPath:path) - if let files=try? FileManager.default.contentsOfDirectory(at:directory,includingPropertiesForKeys:nil), - let ipa=files.first(where:{$0.pathExtension=="ipa"}) { output=ipa;assets=directory.appendingPathComponent("romfs") } + let prepared=IOSPackageOutput.load(record:record,split:splitIOS) else {return} + output=prepared.ipa;assets=prepared.romfs } + func build() { guard let folder, !running, !target.isEmpty else {return} let script=repo.appendingPathComponent("scripts/prepare_ios.sh") @@ -124,6 +126,7 @@ import Darwin task.executableURL=URL(fileURLWithPath:"/usr/bin/python3") var arguments=[repo.appendingPathComponent("desktop/NxNativeEasy/build_session.py").path,script.path,"--target",target,folder.path] arguments += ["--keys-folder",keysFolder.path] + if target=="ios" && !splitIOS {arguments.append("--full-only")} task.arguments=arguments;task.currentDirectoryURL=repo task.standardOutput=pipe;task.standardError=pipe var environment=ProcessInfo.processInfo.environment @@ -133,7 +136,7 @@ import Darwin logURL=log;environment["NXE_BUILD_LOG"]=log.path;environment["NXE_GAME_DIR"]=folder.path startedAt=Date();lastOutputAt=nil;activity=BuildActivity() task.environment=environment - running=true;cancelled=false;failed=false;showDetails=false;output=nil;assets=nil;transcript="" + installerReady=false;running=true;cancelled=false;failed=false;showDetails=false;output=nil;assets=nil;transcript="" phase="Preparando herramientas…";detail="Puedes dejar esta ventana abierta. No necesitas usar Terminal." pipe.fileHandleForReading.readabilityHandler={ [weak self] handle in let data=handle.availableData @@ -151,6 +154,7 @@ import Darwin } else if status==0 { self.restoreOutput() self.failed=self.output==nil + self.installerReady = !self.isMac && self.output != nil self.phase=self.output == nil ? "Terminó, pero no encontramos la app." : (self.isMac ? "Tu juego está listo para Mac." : "Tu juego está listo para instalar en iPhone / iPad.") self.detail=self.output == nil ? "Usa Ver error para revisar lo que ocurrió." : "Pulsa Instalar en iPhone / iPad. Inicia sesión con tu cuenta personal para firmar e instalar el juego y sus datos." } else { @@ -428,6 +432,11 @@ struct ContentView: View { Spacer() Button(model.keysFolder == nil ? model.text("Elegir") : model.text("Cambiar")) {model.chooseKeysFolder()}.disabled(model.running || updater.busy || sideload.busy).accessibilityLabel(model.text("Carpeta de llaves")) } + if model.target=="ios" { + Toggle(model.text("IPA ligera: separar los datos del juego"),isOn:$model.splitIOS).disabled(model.running || updater.busy || sideload.busy) + Text(model.text(model.splitIOS ? "Recomendado: la app pesa menos y el instalador copia romfs por separado." : "IPA completa: incluye el juego dentro. Ocupa más y cada renovación vuelve a transferir toda la app.")).font(.caption).foregroundStyle(.secondary) + Label(model.text("Carpeta y llaves → Preparar → Cuenta Apple → Instalar y copiar datos"),systemImage:"arrow.right.circle").font(.caption).foregroundStyle(.secondary) + } if model.running { Divider().overlay(Color.white.opacity(0.08)) HStack { @@ -527,7 +536,10 @@ struct ContentView: View { }.frame(minWidth:760,minHeight:530).background(CompactWindow(height:(model.isMac ? 740 : 680)+(updater.visible ? 95 : 0)+(sideload.showsRefreshNotice ? 70 : 0))).preferredColorScheme(.dark) .onReceive(NotificationCenter.default.publisher(for:Notification.Name("nxnative.openAnalysis"))) {_ in if !model.running && !updater.busy {model.showAnalysis=true}} .sheet(isPresented:$model.showSaves) {SavesHelpView(folder:model.folder,isMac:model.isMac,text:model.text,language:model.language)} - .sheet(isPresented:$model.showDeviceLibrary) {SideloadLibraryView(model:sideload,text:model.text,canInstall:!model.running && !updater.busy)} + .onChange(of:model.installerReady) {_,ready in + if ready,let ipa=model.output,let folder=model.folder {sideload.configure(ipa:ipa,folder:folder,romfs:model.assets);model.showSideload=true;model.installerReady=false} + } + .sheet(isPresented:$model.showDeviceLibrary,onDismiss:{if model.chooseGameAfterDeviceLibrary {model.chooseGameAfterDeviceLibrary=false;model.target="ios";if !model.chooseFolder() {sideload.cancelPendingUpdate()}}}) {SideloadLibraryView(model:sideload,text:model.text,canInstall:!model.running && !updater.busy,prepareGame:{model.chooseGameAfterDeviceLibrary=true})} .sheet(isPresented:$model.showSideload) {SideloadView(model:sideload,text:model.text)} .sheet(isPresented:$model.showAnalysis) {if let game=model.folder {GameAnalysisView(repo:model.repo,game:game,text:model.text)} else {Text(model.text("Primero selecciona la carpeta de un juego preparado.")).padding(24)}} .sheet(isPresented:$model.showCatalog) {GameCatalogView(model:model)} diff --git a/desktop/NxNativeEasy/tests/sideload_check.swift b/desktop/NxNativeEasy/tests/sideload_check.swift index 941177f..c531213 100644 --- a/desktop/NxNativeEasy/tests/sideload_check.swift +++ b/desktop/NxNativeEasy/tests/sideload_check.swift @@ -16,7 +16,7 @@ import Foundation emit({'event':'hello','protocol':1,'architecture':'aarch64'}) for line in sys.stdin: value=json.loads(line) - if value['op']=='devices':emit({'event':'devices','items':[{'udid':'SYNTHETIC-DEVICE','name':'Test iPad'}]}) + 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':emit({'event':'installed_apps','items':[{'id':'org.game.custom','name':'Test game'}]}) elif value['op']=='login': assert value['password']=='synthetic-password-only' @@ -30,6 +30,9 @@ import Foundation assert value['expected_id']=='org.game.custom' assert value['sha256']=='a'*64 and 'romfs' not in value emit({'event':'installed','effective_id':value['expected_id'],'expires':time.time()+86400,'sha256':'a'*64}) + 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'}]}) elif value['op']=='logout':emit({'event':'signed_out'}) elif value['op']=='quit':break """.write(to:helper,atomically:true,encoding:.utf8) @@ -37,12 +40,18 @@ import Foundation let missing=SideloadModel(stateURL:root.appendingPathComponent("missing-jobs.json"),defaults:defaults,helperURL:root.appendingPathComponent("missing-helper"));missing.listDevices();assert(!missing.busy && missing.error != nil) let model=SideloadModel(stateURL:root.appendingPathComponent("jobs.json"),defaults:defaults,helperURL:helper) model.listDevices();try await wait { !model.busy && model.apps.count==1 } - assert(model.device=="SYNTHETIC-DEVICE") + assert(model.device=="SYNTHETIC-DEVICE" && model.devices.count==1 && model.devices[0].model=="iPad-test" && model.devices[0].connections.contains("USB")) model.email=" TEST@example.org ";model.signIn(password:"synthetic-password-only",remember:false) try await wait {model.codeRequired};assert(model.busy && !model.authenticated) model.submitCode("123456");try await wait {model.authenticated && !model.busy} assert(model.email=="test@example.org" && model.teams.count==1 && model.team.isEmpty) assert(defaults.string(forKey:"sideloadEmail")==nil) + model.inspectAccountDevices();assert(!model.busy && model.accountDevices.isEmpty) + model.team="ABCDE12345";model.inspectAccountDevices();try await wait {!model.busy && model.accountDevices.count==1} + assert(model.accountDeviceTeam==model.team) + assert(!model.logs.map(\.message).joined().contains("synthetic-password-only")) + assert(!model.logs.map(\.message).joined().contains("123456")) + assert(!model.logs.map(\.message).joined().contains("test@example.org")) model.signOut();try await wait {!model.authenticated};model.cancel() let now=Date();var job=RefreshJob(id:"test",ipa:"/synthetic.ipa",gameFolder:root.path,originalID:"org.game",effectiveID:"org.game.custom",team:"ABCDE12345",udid:"test",account:RefreshPolicy.account("Test@Example.org"),sha256:"test",expires:now.addingTimeInterval(24*3600),enabled:true) assert(RefreshPolicy.due(job,now:now,account:RefreshPolicy.account("test@example.org"))) @@ -70,6 +79,21 @@ import Foundation assert(model.error==nil && model.jobs.count==2 && !model.jobs.first(where:{$0.id==job.id})!.enabled && model.jobs.first(where:{$0.id==job.id})!.sha256==job.sha256) model.forget(job);assert(model.jobs.count==1 && model.jobs[0].id==second.id && FileManager.default.fileExists(atPath:ipa.path)) let empty=SideloadModel(stateURL:root.appendingPathComponent("jobs.json"),defaults:defaults,helperURL:helper);assert(empty.jobs.count==1 && empty.jobs[0].id==second.id) + let lightweight=root.appendingPathComponent("light");try FileManager.default.createDirectory(at:lightweight,withIntermediateDirectories:true) + try Data("synthetic-ipa".utf8).write(to:lightweight.appendingPathComponent("test.ipa")) + let packaging:[String:Any]=["full_ipa":ipa.path,"light_dir":lightweight.path,"packaging":"full"] + assert(IOSPackageOutput.load(record:packaging,split:false)?.ipa==ipa) + 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") + 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.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 a210535..a6288e4 100644 --- a/desktop/release.json +++ b/desktop/release.json @@ -1,5 +1,5 @@ { - "version": "1.8.1", - "build": 13, + "version": "1.9.0", + "build": 14, "minimumMacOSMajor": 26 } diff --git a/dm/ios/Sources/main.m b/dm/ios/Sources/main.m index 1f05105..b3523bc 100644 --- a/dm/ios/Sources/main.m +++ b/dm/ios/Sources/main.m @@ -17,7 +17,12 @@ static char *g_argv[4]; static int g_argc; static NSString *romfs_locate(void) { NSFileManager *fm = NSFileManager.defaultManager; NSString *doc = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES)[0]; - for (NSString *d in @[[doc stringByAppendingPathComponent:@"romfs"], [NSBundle.mainBundle.resourcePath stringByAppendingPathComponent:@"romfs"]]) + NSString *external = [doc stringByAppendingPathComponent:@"romfs"]; + NSString *bundled = [NSBundle.mainBundle.resourcePath stringByAppendingPathComponent:@"romfs"]; + // A complete native installation must not boot stale externally copied data. + // Legacy/full apps still accept an intentional Documents override by default. + BOOL preferBundled = [NSBundle.mainBundle.infoDictionary[@"DMPreferBundledRomFS"] boolValue]; + for (NSString *d in (preferBundled ? @[bundled, external] : @[external, bundled])) if ([fm fileExistsAtPath:[d stringByAppendingPathComponent:@DM_GAME_ROMFS_MARKER]]) return d; // only a directory containing the game profile's romfs_marker path counts (Mario: System/Resource, the resource size table) return nil; } diff --git a/docs/agents/07-upstream-maintenance.md b/docs/agents/07-upstream-maintenance.md index a5e3a1a..42981c6 100644 --- a/docs/agents/07-upstream-maintenance.md +++ b/docs/agents/07-upstream-maintenance.md @@ -22,6 +22,12 @@ appear everywhere without being forgotten. No recurring schedule is implied. Keep personal commit attribution, original upstream authors and licensing. Preserve the isideload exact-identifier patch, signed/profile identity checks, Keychain session storage, redacted stdio and RomFS-only transfer capability. + Preserve UDID deduplication (USB preferred), device metadata/search/update + selection, separate connected/registered inventories and logs without raw + credential/provider payloads. Full/light packaging must retain the bundled + RomFS priority flag and legacy-record rebuild gate; never delete external + content or saves to switch modes. Show actual registration results, not an + invented remaining free-account quota. 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. diff --git a/docs/agents/symptoms.md b/docs/agents/symptoms.md index 97528ef..b6b45cd 100644 --- a/docs/agents/symptoms.md +++ b/docs/agents/symptoms.md @@ -13,6 +13,7 @@ One line per symptom in the [subsystem references](../reference/): what you see - preparation appears stuck; the player cannot inspect compilation output until a failure. → [Desktop preparation shows a spinner with no build activity](../reference/packing.md#desktop-preparation-shows-a-spinner-with-no-build-activity) - the official Rust component shell installer rejects part of `--prefix` as an unknown option; a partial LLVM setup can also make `rust-objcopy` fail to load `libLLVM.dylib`. → [Native sideload toolchain install rejects a workspace path with spaces](../reference/packing.md#native-sideload-toolchain-install-rejects-a-workspace-path-with-spaces) - a replacement signer can append a second team suffix or produce another app identifier, separating the user from the existing app container. → [A sideload signer changes an existing game's bundle identifier](../reference/packing.md#a-sideload-signer-changes-an-existing-games-bundle-identifier) +- a complete game update includes new bundled RomFS but an existing Documents/romfs can take priority when the app boots. → [A complete iOS IPA can still select older external game data](../reference/packing.md#a-complete-ios-ipa-can-still-select-older-external-game-data) ## Rewriter diff --git a/docs/personal-ios.md b/docs/personal-ios.md index 9cb49ef..d59dc4b 100644 --- a/docs/personal-ios.md +++ b/docs/personal-ios.md @@ -435,3 +435,29 @@ assertions, account rejection and non-destructive record removal. The production ARM64 bundle compiled and its signature verified; localization, documentation and 78-profile catalog checks passed. No runtime, shader or Rust helper change was needed. No real account login, signing or physical-device install was performed. + +## Guided device workspace and complete iOS packaging (1.9) + +The device workspace now exposes metadata, transport/readiness, installed app +search, update selection, Apple-account access and separate team registration +queries. USB/Wi-Fi duplicates merge by UDID with USB preferred. After preparing +an iOS game the installer opens, but signing/installing still needs explicit +approval in the UI. Lightweight mode remains default; full-only mode includes +RomFS and skips separate transfer. The new `DMPreferBundledRomFS` runtime flag +prevents an older Documents/romfs from shadowing a complete native installation; +unflagged apps retain legacy overrides. No saves or external directories are +removed. Installation activity is bounded, timestamped, copyable and privately +retained, without raw protocol/account credentials. + +Validation: 11 Rust cases passed; the expanded Swift fixture covers duplicate +connections, metadata, installed-target continuity, explicit team-account device +queries, full/light output selection, legacy full-record refusal and private log +permissions/redaction. All 64 script cases completed (two optional dependency/ +compiled-helper checks skipped in that suite); documentation/localization and +78-profile catalog gates passed. The unsigned iOS compile check compiled 178 +files with zero failures. Read-only real-device probing returned one unique +connected iPhone with model/OS and 184 installed user apps. No Apple-account +login, registration query, signing or installation was performed. The Mac +regression harness was invoked but found no registered Mac Dread build; no +physical gameplay or full/light migration comparison is claimed. The shader +chain is unchanged. Cloud work remains paused; saves remain local/manual. diff --git a/docs/reference/packing.md b/docs/reference/packing.md index cc7f2f9..0e9b09a 100644 --- a/docs/reference/packing.md +++ b/docs/reference/packing.md @@ -70,3 +70,10 @@ - **Cause:** isideload 0.5.0 unconditionally appended the team ID to the supplied bundle ID, even when the caller already knows the effective installed identifier. - **Fix:** the small vendored MIT patch preserves the caller's exact final ID. Our helper selects an actual existing device app, checks that it matches the game, verifies the signed/provisioned identity and never uninstalls the previous app to force an update. Original preparation IPAs stay unsigned. Tests cover custom identifiers and mismatch refusal; real-account/device migration still requires validation. - **Found on:** source review and synthetic tests for the [integrated installer](../sideloading.md#user-flow), with no existing user installation modified. + +### A complete iOS IPA can still select older external game data + +- **Symptom:** a complete game update includes new bundled RomFS but an existing Documents/romfs can take priority when the app boots. +- **Cause:** the legacy iOS boot search intentionally checks Documents first to support external game-content overrides; preserving the container across an update also preserves that older directory. +- **Fix:** the native signer marks bundles containing RomFS with `DMPreferBundledRomFS`. The iOS boot search honors the flag by checking bundled content first, with Documents as fallback. Unflagged apps retain the old override behavior. The desktop full-mode preparation gate requires a new preparation record; it does not silently reuse an older runtime. No save or external content is deleted. Physical mode migration remains unvalidated. +- **Found on:** [native installer source review](../sideloading.md#guided-device-workspace-19), not a verified gameplay regression. diff --git a/docs/sideloading.md b/docs/sideloading.md index 82ccab1..40a4735 100644 --- a/docs/sideloading.md +++ b/docs/sideloading.md @@ -162,3 +162,74 @@ The implementation covers our prepared-game installation lifecycle. It is not a full binary/source migration of Sideloadly: no proprietary code is available to port, and arbitrary IPA injection/plugins and all of its advanced options are not implemented. Intel Sideloadly is not required by the native code path. + +## Guided device workspace (1.9) + +The device workspace separates three different inventories: + +- **Connected devices:** read-only usbmuxd/Lockdown discovery. Duplicate USB/Wi-Fi + entries are merged by UDID, preferring USB for operations. Device cards show + Apple's model identifier, OS version, available transports and trust/readiness. + A model identifier is not a guessed marketing model name. +- **Installed apps:** the selected device's actual installation-proxy user-app + list, with names, effective bundle IDs, versions and search. These do not need + an Apple-account login and include apps outside nxnative. **Update using my + files** retains that target/device, returns to game-folder preparation and + carries the target into installation. Matching game identity is checked again + before overwriting; selecting an unrelated app does not authorize replacing it. +- **Apple-account devices:** after login and explicit team selection, **Read my + Apple account devices** queries that team's actual registrations. This is not + the same as a list of devices currently connected. A query failure is shown, + not interpreted as zero registrations or available free slots. + +Apple documents a Personal Team limit of up to three test devices and seven-day +profiles in its [account overview](https://developer.apple.com/help/account/basics/about-your-developer-account). +Sideloadly's [FAQ](https://sideloadly.io/faq.html) also describes the three-active-app +limit for free accounts. The UI shows returned registrations and team type; it +cannot infer a reliable remaining quota from installed user-app counts, count +paid teams as free, remove registrations or bypass Apple's limits. No speculative +"slots remaining" counter is displayed. + +### Folder to device + +Choose iPhone/iPad, select your own game folder and keys, select packaging, and +press Prepare. Compilation already exposes live and retained compiler logs. +After success the native installer opens automatically: select the device, sign +in with your Apple account/2FA, explicitly choose your team and existing target, +then approve signing/installing. Installation remains an explicit button; finishing +compilation never signs or installs silently. Device-workspace account access is +also available before preparing a game. A new source folder alone does not +install anything. + +**Lightweight IPA** is the default. RomFS remains separate and the installer +copies it with bounded progress and staging/recovery. **Complete IPA** uses +`--full-only`, omits separate content transfer and includes the game inside the +app; every signature renewal must transfer that larger app. Archive expansion is +bounded to 64 GiB; use lightweight mode for larger content or constrained storage. Older preparation +records require a new full preparation before using this mode. During native +signing a bundle with RomFS gets `DMPreferBundledRomFS`; the updated game runtime +then checks bundled content first, falling back to Documents when absent. This +prevents a complete game update from booting an older external RomFS. It does not +delete the previous external directory or touch saves. Legacy apps without the +flag retain their intentional Documents override. Physical content-mode migration +still needs device validation. + +**Installation activity** shows timestamped, bounded events for discovery, login +state, device registration, signing, signature verification, installation and +content transfer. Percentages come from actual helper callbacks when available; +otherwise progress is indeterminate with elapsed time. The viewer can copy the +log and reveal a retained `nxnative-tools/logs/install-.log` in the game folder. +Only fixed local status/error messages and numeric progress/Apple error codes +are logged, never raw protocol payloads, passwords, 2FA codes, Apple responses, +account names, device IDs or session tokens. The compiler log remains separate. + +### Honest parity boundary + +The REA investigation was read-only Mach-O inspection, not full decompilation or +a recovered Sideloadly source tree. This project implements our game workflow +using a separately licensed open-source signer. It has no claim of full Sideloadly +parity: arbitrary IPA/plugin injection, all advanced signing options and its +unattended background-daemon behavior are not implemented. Auto-renewal requires +our launcher running. Real connected-device metadata and installed-app discovery +were checked locally; account registrations, real provisioning/signing, physical +RomFS recovery and unattended renewal remain unvalidated. Keep the fallback. diff --git a/native/sideload-helper/src/main.rs b/native/sideload-helper/src/main.rs index e833e6d..4cdf4cd 100644 --- a/native/sideload-helper/src/main.rs +++ b/native/sideload-helper/src/main.rs @@ -3,8 +3,8 @@ mod idle; mod romfs; use idevice::{ IdeviceService, - provider::UsbmuxdProvider, - usbmuxd::{UsbmuxdAddr, UsbmuxdConnection}, + provider::{IdeviceProvider, UsbmuxdProvider}, + usbmuxd::{Connection, UsbmuxdAddr, UsbmuxdConnection, UsbmuxdDevice}, }; use isideload::{ anisette::remote_v3::RemoteV3AnisetteProvider, @@ -109,25 +109,65 @@ fn effective_id( } async fn provider(udid: &str) -> Result { let mut mux = UsbmuxdConnection::default().await?; - let devices = mux.get_devices().await?; + let devices = unique_devices(mux.get_devices().await?); let device = devices .into_iter() .find(|d| d.udid == udid) .ok_or_else(|| report!("Device disconnected"))?; Ok(device.to_provider(UsbmuxdAddr::from_env_var()?, "nxnative-easy")) } +// usbmuxd can report the same paired device over USB and Wi-Fi. Keep one +// stable identity and prefer USB for metadata, app queries and installations. +fn unique_devices(mut devices: Vec) -> Vec { + devices.sort_by_key(|d| { + ( + d.udid.clone(), + !matches!(d.connection_type, Connection::Usb), + ) + }); + devices.dedup_by(|a, b| a.udid == b.udid); + devices +} +async fn device_details(p: &UsbmuxdProvider) -> Result { + let mut lockdown = idevice::lockdown::LockdownClient::connect(p).await?; + lockdown.start_session(&p.get_pairing_file().await?).await?; + lockdown + .get_value(None, None) + .await? + .into_dictionary() + .ok_or_else(|| report!("Missing device metadata")) +} async fn devices() -> Result<(), Report> { let mut mux = UsbmuxdConnection::default().await?; + let all = mux.get_devices().await?; let mut result = Vec::new(); - for device in mux.get_devices().await? { + for device in unique_devices(all.clone()) { let p = device.to_provider(UsbmuxdAddr::from_env_var()?, "nxnative-easy"); - let name = tokio::time::timeout(Duration::from_secs(3), IdeviceInfo::from_device(&p)) + let details = tokio::time::timeout(Duration::from_secs(5), device_details(&p)) .await .ok() - .and_then(Result::ok) - .map(|i| i.name) - .unwrap_or_else(|| "iPhone / iPad — unlock and trust this Mac".into()); - result.push(json!({"udid":device.udid,"name":name})); + .and_then(Result::ok); + let value = |key: &str| { + details + .as_ref() + .and_then(|d| d.get(key)) + .and_then(plist::Value::as_string) + .unwrap_or("") + .to_string() + }; + let connections: Vec<_> = all + .iter() + .filter(|d| d.udid == device.udid) + .map(|d| match d.connection_type { + Connection::Usb => "USB", + Connection::Network(_) => "Wi-Fi", + Connection::Unknown(_) => "Unknown", + }) + .collect::>() + .into_iter() + .collect(); + let name = value("DeviceName"); + result.push(json!({"udid":device.udid,"name":if name.is_empty(){"iPhone / iPad".to_string()}else{name},"model":value("ProductType"),"os":value("ProductVersion"),"kind":value("DeviceClass"),"ready":details.is_some(),"connections":connections})); } emit(json!({"event":"devices","items":result})); Ok(()) @@ -145,7 +185,7 @@ async fn installed(udid: &str) -> Result, Report> { .or_else(|| app.get("CFBundleName")) .and_then(plist::Value::as_string) .unwrap_or(&id); - Some(json!({"id":id,"name":name})) + Some(json!({"id":id,"name":name,"version":app.get("CFBundleShortVersionString").and_then(plist::Value::as_string).unwrap_or("")})) }) .collect(); Ok(result) @@ -220,7 +260,7 @@ async fn login(request: &mut Value, restore: bool) -> Result { .build(); let choices: Vec<_> = teams .iter() - .map(|t| json!({"id":t.team_id,"name":t.name})) + .map(|t| json!({"id":t.team_id,"name":t.name,"kind":t.r#type,"status":t.status})) .collect(); emit(json!({"event":"signed_in","teams":choices})); Ok(Session { @@ -275,7 +315,8 @@ fn extract(ipa: &Path, stage: &Path) -> Result { expanded = expanded .checked_add(entry.size()) .ok_or_else(|| report!("IPA too large"))?; - if expanded > 16 * 1024 * 1024 * 1024 { + // Full game IPAs can exceed 16 GiB; retain a bounded 64 GiB ceiling. + if expanded > 64 * 1024 * 1024 * 1024 { bail!("IPA too large") } let output = stage.join(relative); @@ -413,6 +454,11 @@ async fn install(session: &mut Session, request: &Value) -> Result<(), Report> { } // Rebind the main bundle and its extensions using the SDK's own prefix rules. rebind_app(&app, original, &base)?; + if app.join("romfs").is_dir() { + let mut info: plist::Dictionary = plist::from_file(app.join("Info.plist"))?; + info.insert("DMPreferBundledRomFS".into(), plist::Value::Boolean(true)); + plist::to_file_binary(app.join("Info.plist"), &info)?; + } if request["update"].as_bool() == Some(true) { let name = metadata .get("CFBundleDisplayName") @@ -429,6 +475,7 @@ async fn install(session: &mut Session, request: &Value) -> Result<(), Report> { bail!("Selected installed app does not match this game") } } + emit(json!({"event":"progress","phase":"Registering device"})); session .loader .get_dev_session() @@ -449,6 +496,7 @@ async fn install(session: &mut Session, request: &Value) -> Result<(), Report> { { bail!("Signed identity mismatch; not installed") } + emit(json!({"event":"progress","phase":"Verifying signature"})); let expires = profile_details(&signed, &effective, team_id)?; let verify = std::process::Command::new("/usr/bin/codesign") .args(["--verify", "--deep", "--strict"]) @@ -502,6 +550,16 @@ async fn main() { Err(e) } }, + "account_devices" => match &mut session { + Some(value) => async { + let id=text(&request,"team")?; + let team=value.teams.iter().find(|t|t.team_id==id).cloned().ok_or_else(||report!("Select your team"))?; + let items=value.loader.get_dev_session().list_devices(&team,None).await?; + emit(json!({"event":"account_devices","team":id,"items":items.iter().map(|d|json!({"udid":d.device_number,"name":d.name,"status":d.status})).collect::>()})); + Ok(()) + }.await, + None => Err(report!("Sign in first")), + }, "install" => match &mut session { Some(value) => install(value, &request).await, None => Err(report!("Sign in first")), @@ -554,6 +612,28 @@ async fn main() { #[cfg(test)] mod tests { use super::*; + #[test] + fn usb_and_wifi_are_one_device_with_usb_preferred() { + let usb = UsbmuxdDevice { + udid: "same".into(), + device_id: 2, + connection_type: Connection::Usb, + }; + let wifi = UsbmuxdDevice { + udid: "same".into(), + device_id: 1, + connection_type: Connection::Network("127.0.0.1".parse().unwrap()), + }; + for items in [vec![wifi.clone(), usb.clone()], vec![ + usb.clone(), + wifi.clone(), + ]] { + let result = unique_devices(items); + assert_eq!(result.len(), 1); + assert_eq!(result[0].device_id, 2); + } + } + #[test] fn identifiers_preserve_update_identity() { assert_eq!( diff --git a/scripts/build_ios.py b/scripts/build_ios.py index ba61d9d..3b6e0be 100755 --- a/scripts/build_ios.py +++ b/scripts/build_ios.py @@ -196,7 +196,7 @@ def main(argv=None): print(json.dumps({'ev':'stage','name':'Export lightweight IPA','step':'Separating game data','k':0,'n':0}),flush=True) light_dir = None if args.full_only else split_ipa(output) record = dict(schema=1, **settings, version=info.get('CFBundleShortVersionString'), - source_revision=revision, full_ipa=str(output), light_dir=str(light_dir) if light_dir else None) + source_revision=revision, packaging="full" if args.full_only else "split", full_ipa=str(output), light_dir=str(light_dir) if light_dir else None) temp = state_path.with_suffix('.json.tmp') temp.write_text(json.dumps(record,indent=2)+'\n'); temp.replace(state_path) print('Build complete. Install over the existing app using the same personal account and effective bundle ID.')