From 34492fdb4dff29d045f70d5d0dd2f27b583f96db Mon Sep 17 00:00:00 2001 From: Erick Sanchez <7268597+nalancer08@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:48:01 -0600 Subject: [PATCH] Restore remembered Apple sessions automatically without discarding saved access --- README-es.md | 2 + README.md | 2 + desktop/NxNativeEasy/LibraryWorkspace.swift | 10 +++ desktop/NxNativeEasy/Localization.swift | 7 ++ desktop/NxNativeEasy/Sideloading.swift | 64 +++++++++++++++---- .../NxNativeEasy/tests/sideload_check.swift | 43 ++++++++++++- desktop/release.json | 4 +- docs/agents/07-upstream-maintenance.md | 3 +- docs/personal-ios.md | 20 ++++++ docs/sideloading.md | 40 ++++++++++++ 10 files changed, 179 insertions(+), 16 deletions(-) diff --git a/README-es.md b/README-es.md index 5935cc6..27a90ef 100644 --- a/README-es.md +++ b/README-es.md @@ -58,6 +58,8 @@ Nuestro main incorpora la revisión del original registrada en el [informe de in El motor actual funciona **sin conexión**. No admite los servicios en línea de Nintendo, LAN/netplay ni Windows. Una suscripción a Nintendo Switch Online no los activa aquí. Usar varios controles en un mismo Mac es una función distinta. [Cómo funcionan el código, los mods y la red](docs/development.md#game-binaries-mods-and-networking). +Con **Recordar sesión**, la app intenta recuperar tu cuenta Apple del Llavero de este Mac al volver a abrir. Si falla, puedes reintentar o identificarte de nuevo; no se guarda la contraseña. + ## Idioma La app inicia en **English**. Cambia a **Español** en **Configuración → General**; la elección se guarda en este Mac. Cambia el idioma de la interfaz, no el del juego. Los detalles técnicos se muestran en su idioma original. diff --git a/README.md b/README.md index f25bb9e..3511a4e 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,8 @@ Our main incorporates the upstream revision recorded in the [integration review] The current runtime plays **offline**. Nintendo online services, LAN/netplay and Windows are not supported. A Nintendo Switch Online subscription does not enable them here. Local controllers on one Mac are a separate feature. [How the game code, mods and networking work](docs/development.md#game-binaries-mods-and-networking). +When **Remember session** is enabled, the app attempts to restore your Apple session from this Mac’s Keychain after reopening. If recovery fails, retry or sign in again; the password is not saved. + ## Language The app starts in **English**. Choose **Español** under **Settings → General**; your selection is remembered on this Mac. This changes the interface language, not the game's language. Build diagnostics keep their original language. diff --git a/desktop/NxNativeEasy/LibraryWorkspace.swift b/desktop/NxNativeEasy/LibraryWorkspace.swift index 19e134c..1bf1a5d 100644 --- a/desktop/NxNativeEasy/LibraryWorkspace.swift +++ b/desktop/NxNativeEasy/LibraryWorkspace.swift @@ -144,6 +144,15 @@ struct LibraryWorkspace:View { } } } + if sideload.recoveringSession && page != .settings && !model.showSideload { + Divider() + HStack(spacing:12) { + ProgressView().controlSize(.small) + Text(model.text("Recuperando tu sesión del Llavero…")).font(.callout) + Spacer() + Button(model.text("Cancelar")){sideload.cancel()} + }.padding(.horizontal,24).padding(.vertical,12) + } if model.running || model.failed || !model.activity.history.isEmpty { Divider() BuildDock(model:model,show:{page = .prepare;model.showDetails=true}) @@ -153,6 +162,7 @@ struct LibraryWorkspace:View { if model.folder != nil && library.games.isEmpty && library.error==nil {remember()} checkBuildAdvice() if !occupied && !library.games.isEmpty {sideload.listDevices()} + sideload.recoverRememberedSessionIfNeeded() } .dropDestination(for:URL.self) {items,_ in guard !occupied else {return false} diff --git a/desktop/NxNativeEasy/Localization.swift b/desktop/NxNativeEasy/Localization.swift index d3347a9..c796cb6 100644 --- a/desktop/NxNativeEasy/Localization.swift +++ b/desktop/NxNativeEasy/Localization.swift @@ -12,6 +12,13 @@ enum EasyLanguage: String, CaseIterable { struct EasyStrings { static let english: [String:String] = [ + "Actividad":"Activity", + "Recuperando tu sesión del Llavero…":"Restoring your Keychain session…", + "No pudimos recuperar la sesión guardada. Revisa Internet y el acceso al Llavero, o vuelve a iniciar sesión.":"Could not restore the saved session. Check your connection and Keychain access, or sign in again.", + "Recuperar tu sesión":"Restore your session", + "Recordamos tu cuenta en este Mac. Recupera el acceso desde el Llavero o entra de nuevo si Apple lo solicita.":"Your account is remembered on this Mac. Restore access from Keychain, or sign in again if Apple requires it.", + "Reintentar sesión guardada":"Retry saved session", + "Entrar con contraseña":"Sign in with password", "Configuración":"Settings", "General":"General", "Sección de configuración":"Settings section", diff --git a/desktop/NxNativeEasy/Sideloading.swift b/desktop/NxNativeEasy/Sideloading.swift index 3b83436..23bb4fc 100644 --- a/desktop/NxNativeEasy/Sideloading.swift +++ b/desktop/NxNativeEasy/Sideloading.swift @@ -113,12 +113,21 @@ enum RefreshPolicy { private var loggedPhase="" private var loggedBucket = -1 @Published var device="" - @Published var team="" + @Published var team="" { + didSet { + if authenticated,hasRememberedSession,teams.contains(where:{$0.id==team}) {defaults.set(team,forKey:"sideloadTeam."+RefreshPolicy.account(email))} + } + } @Published var existing="" @Published var email="" @Published var anisette="https://ani.iloader.app" @Published var authenticated=false - @Published var busy=false + @Published private(set) var recoveringSession=false + private var recoveryRequested=false + private var automaticRecoveryAttempted=false + @Published var busy=false { + didSet {if !busy && recoveryRequested {Task {@MainActor [weak self] in self?.recoverRememberedSessionIfNeeded()}}} + } @Published var codeRequired=false @Published var smsNumbers:[TrustedSMS]=[] @Published var error:String? @@ -272,14 +281,20 @@ enum RefreshPolicy { let data=handle.availableData;if data.isEmpty {handle.readabilityHandler=nil;return} Task {@MainActor in self?.receive(data)} } - task.terminationHandler={ [weak self] finished in Task {@MainActor in guard let self,self.process === finished else {return};self.process=nil;self.input=nil;self.scopedDiscovery=false;self.authenticated=false;self.codeRequired=false;if self.busy {self.error="Instalación detenida. Vuelve a intentar; las partidas no se tocaron."};self.busy=false;self.activeRefresh=nil;self.installIntent=nil;self.refreshAuthenticated=false}} + task.terminationHandler={ [weak self] finished in Task {@MainActor in + guard let self,self.process === finished else {return} + let recovering=self.recoveringSession + self.process=nil;self.input=nil;self.scopedDiscovery=false;self.authenticated=false;self.codeRequired=false;self.recoveringSession=false;self.pendingRemember=nil + if self.busy {self.error=recovering ? "No pudimos recuperar la sesión guardada. Revisa Internet y el acceso al Llavero, o vuelve a iniciar sesión." : "Instalación detenida. Vuelve a intentar; las partidas no se tocaron."} + self.busy=false;self.activeRefresh=nil;self.installIntent=nil;self.refreshAuthenticated=false + }} do {process=task;try task.run()} catch {process=nil;input=nil;self.error="No pudimos iniciar el instalador nativo."} } private func send(_ value:[String:Any]) { - connect();guard let input,let process,process.isRunning else {busy=false;return} + connect();guard let input,let process,process.isRunning else {recoveringSession=false;busy=false;return} 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} + catch {self.error="No pudimos comunicarnos con el instalador.";recoveringSession=false;busy=false} } 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() { @@ -297,6 +312,7 @@ enum RefreshPolicy { func signIn(password:String,remember:Bool) { guard !busy,!email.isEmpty,!password.isEmpty else {return} + recoveryRequested=false;automaticRecoveryAttempted=true;recoveringSession=false email=email.trimmingCharacters(in:.whitespacesAndNewlines).lowercased() busy=true;error=nil;startedAt=Date();fraction=nil;phase="Iniciando sesión con Apple…" record("Iniciando sesión con Apple…") @@ -307,8 +323,8 @@ enum RefreshPolicy { func sendToDevices() {phase="Solicitando un nuevo código…";codeRequired=false;send(["op":"send_to_devices"])} func sendSMS(_ number:Int) {phase="Solicitando un nuevo código…";codeRequired=false;send(["op":"sms","number":number])} func resend() {phase="Solicitando un nuevo código…";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 signOut() {guard !busy else {return};recoveryRequested=false;automaticRecoveryAttempted=true;defaults.removeObject(forKey:"sideloadTeam."+RefreshPolicy.account(email));send(["op":"logout","email":email]);defaults.removeObject(forKey:"sideloadEmail");for index in jobs.indices {jobs[index].enabled=false};saveJobs()} + func cancel() {recoveryRequested=false;automaticRecoveryAttempted=true;pendingRemember=nil;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()}}} 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} @@ -375,13 +391,15 @@ enum RefreshPolicy { 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": + recoveringSession=false;recoveryRequested=false;automaticRecoveryAttempted=true 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,kind:item["kind"] as? String ?? "",freeDeviceLimit:(item["free_device_limit"] as? Int)==3 ? 3 : nil)} if !teams.contains(where:{$0.id==team}) {team=""} + if activeRefresh==nil,team.isEmpty,hasRememberedSession,let saved=defaults.string(forKey:"sideloadTeam."+RefreshPolicy.account(email)),teams.contains(where:{$0.id==saved}) {team=saved} 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;accountDevices=[];accountDeviceTeam="";accountDeviceLimit=nil;accountDevicesCheckedAt=nil;teams=[];team="";busy=false + case "signed_out":recoveringSession=false;recoveryRequested=false;automaticRecoveryAttempted=true;authenticated=false;accountDevices=[];accountDeviceTeam="";accountDeviceLimit=nil;accountDevicesCheckedAt=nil;teams=[];team="";busy=false case "progress": 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} @@ -412,8 +430,9 @@ enum RefreshPolicy { 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."} + if op=="restore" {error="No pudimos recuperar la sesión guardada. Revisa Internet y el acceso al Llavero, o vuelve a iniciar sesión."} if let code=event["apple_code"] as? Int {error=(error ?? "")+" · Apple: \(code)"} - if op=="login" || op=="restore" {authenticated=false;pendingRemember=nil} + if op=="login" || op=="restore" {authenticated=false;pendingRemember=nil;recoveringSession=false} 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 @@ -429,11 +448,20 @@ enum RefreshPolicy { try FileManager.default.setAttributes([.posixPermissions:0o600],ofItemAtPath:stateURL.path) } catch {self.error="La instalación terminó, pero no pudimos guardar su renovación automática."} } + // Restore authentication only. Never starts registration, signing or installation. + func recoverRememberedSessionIfNeeded() { + guard !automaticRecoveryAttempted,!authenticated,hasRememberedSession else {recoveryRequested=false;return} + recoveryRequested=true + guard !busy,activeRefresh==nil else {return} + restoreSession() + } func restoreSession() { guard !busy,!email.isEmpty,defaults.string(forKey:"sideloadEmail")==email else {return} - busy=true;error=nil;startedAt=Date();fraction=nil;phase="Renovando sesión guardada…";record(phase) + recoveryRequested=false;automaticRecoveryAttempted=true;recoveringSession=true;pendingRemember=nil + busy=true;error=nil;startedAt=Date();fraction=nil;phase="Recuperando tu sesión del Llavero…";record(phase) send(["op":"restore","email":email,"anisette_url":anisette]) } + // This is an opt-in marker, not proof that Keychain or Apple will accept the saved session. var hasRememberedSession:Bool {defaults.string(forKey:"sideloadEmail")==email && !email.isEmpty} func select(_ job:RefreshJob) { guard !busy else {return} @@ -469,7 +497,7 @@ enum RefreshPolicy { private func continueRefresh(_ job:RefreshJob) { guard devices.contains(where:{$0.id==job.udid}) else {if manualRefresh {error="Conecta y desbloquea el dispositivo donde instalaste este juego."};activeRefresh=nil;return} guard teams.contains(where:{$0.id==job.team}) || !refreshAuthenticated else {error="La cuenta actual no tiene el equipo original de este juego. No cambiamos su identidad.";activeRefresh=nil;return} - if !refreshAuthenticated {busy=true;phase="Renovando sesión guardada…";send(["op":"restore","email":email,"anisette_url":anisette])} + if !refreshAuthenticated {recoveryRequested=false;automaticRecoveryAttempted=true;recoveringSession=true;busy=true;phase="Recuperando tu sesión del Llavero…";send(["op":"restore","email":email,"anisette_url":anisette])} else {startInstall(job,updating:true,content:nil)} } func setLoginItem(_ enabled:Bool) { @@ -486,6 +514,7 @@ struct SideloadView:View { @Environment(\.dismiss) private var dismiss @State private var approveRegistration=false @State private var installStep=0 + @State private var usePassword=false @State private var password="" @State private var remember=true @State private var autoRefresh=true @@ -516,6 +545,15 @@ struct SideloadView:View { if accountOnly || installStep==1 { Text(text("Una cuenta para todos tus dispositivos en este Mac. Sólo se necesita para firmar, instalar y renovar apps.")).foregroundStyle(.secondary) if !model.authenticated && !model.codeRequired && !model.busy { + if model.hasRememberedSession && !usePassword { + VStack(alignment:.leading,spacing:14) { + Label(text("Recuperar tu sesión"),systemImage:"key.horizontal").font(.headline) + Text(model.email).font(.callout).textSelection(.enabled) + Text(text("Recordamos tu cuenta en este Mac. Recupera el acceso desde el Llavero o entra de nuevo si Apple lo solicita.")).font(.callout).foregroundStyle(.secondary) + Button(text("Reintentar sesión guardada")){model.restoreSession()}.buttonStyle(.borderedProminent) + Button(text("Entrar con contraseña")){usePassword=true}.buttonStyle(.link) + }.padding(20).frame(maxWidth:.infinity,alignment:.leading).background(.white.opacity(0.04),in:RoundedRectangle(cornerRadius:14)) + } else { 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}} @@ -525,6 +563,7 @@ struct SideloadView:View { } 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=""}.buttonStyle(.borderedProminent).disabled(model.busy || model.email.isEmpty || password.isEmpty) + } } else if model.authenticated { Label(text("Sesión iniciada"),systemImage:"person.crop.circle.badge.checkmark") Text(model.email).font(.caption).textSelection(.enabled) @@ -570,6 +609,7 @@ struct SideloadView:View { if !model.busy {Text(text(model.phase)).font(.callout)} }.padding(24) } + .task {model.recoverRememberedSessionIfNeeded()} .onChange(of:model.device){_,_ in approveRegistration=false} .onChange(of:installStep){_,step in if step==2 {approveRegistration=false;if !model.busy {model.inspectAccountDevices()}}} .onChange(of:model.team){_,value in approveRegistration=false;if !value.isEmpty && model.authenticated && !model.busy {model.inspectAccountDevices()}} @@ -799,7 +839,7 @@ struct InstallActivityView:View { 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) { + DisclosureGroup(text("Actividad"),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/tests/sideload_check.swift b/desktop/NxNativeEasy/tests/sideload_check.swift index 5235704..1590ad7 100644 --- a/desktop/NxNativeEasy/tests/sideload_check.swift +++ b/desktop/NxNativeEasy/tests/sideload_check.swift @@ -16,6 +16,7 @@ import Foundation emit({'event':'hello','protocol':1,'architecture':'aarch64','capabilities':['scoped-games-v1','save-export-v1','romfs-copy-v1','registration-consent-v1']}) for line in sys.stdin: value=json.loads(line) + with open(os.path.join(os.path.dirname(__file__),'operations.log'),'a') as audit:audit.write(value['op']+'\\n') 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 @@ -26,7 +27,9 @@ import Foundation elif value['op']=='code': assert value['code']=='123456' emit({'event':'signed_in','teams':[{'id':'ABCDE12345','name':'Personal test team'}]}) - elif value['op']=='restore':emit({'event':'signed_in','teams':[{'id':'ABCDE12345','name':'Personal test team'}]}) + elif value['op']=='restore': + if value['email']=='unavailable@example.invalid':emit({'event':'error','operation':'restore','code':'failed'}) + else:emit({'event':'signed_in','teams':[{'id':'ABCDE12345','name':'Personal test team'}]}) elif value['op']=='install': assert value['automatic']==False and value['update']==True assert value.get('approve_registration') in (False,True) @@ -52,6 +55,44 @@ import Foundation elif value['op']=='quit':break """.write(to:helper,atomically:true,encoding:.utf8) try FileManager.default.setAttributes([.posixPermissions:0o700],ofItemAtPath:helper.path) + let recoverySuite=suite+".recovery",recoveryDefaults:UserDefaults + recoveryDefaults=UserDefaults(suiteName:recoverySuite)! + defer {recoveryDefaults.removePersistentDomain(forName:recoverySuite)} + func restoredCount()->Int { + let operations=(try? String(contentsOf:root.appendingPathComponent("operations.log"),encoding:.utf8)) ?? "" + return operations.split(separator:"\n").filter{$0=="restore"}.count + } + let noSaved=SideloadModel(stateURL:root.appendingPathComponent("recovery/no-saved.json"),defaults:recoveryDefaults,helperURL:helper) + noSaved.recoverRememberedSessionIfNeeded();assert(!noSaved.busy && !noSaved.authenticated && restoredCount()==0) + recoveryDefaults.set("saved@example.invalid",forKey:"sideloadEmail") + let saved=SideloadModel(stateURL:root.appendingPathComponent("recovery/saved.json"),defaults:recoveryDefaults,helperURL:helper) + saved.recoverRememberedSessionIfNeeded();saved.recoverRememberedSessionIfNeeded() + try await wait {saved.authenticated && !saved.busy} + assert(restoredCount()==1 && !saved.recoveringSession && saved.jobs.isEmpty && saved.team.isEmpty) + saved.team="ABCDE12345";saved.cancel() + let restarted=SideloadModel(stateURL:root.appendingPathComponent("recovery/restarted.json"),defaults:recoveryDefaults,helperURL:helper) + restarted.preparedIdentifiers=["org.game.custom"];restarted.listDevices();restarted.recoverRememberedSessionIfNeeded() + try await wait {restarted.authenticated && !restarted.busy && restarted.apps.count==1} + assert(restoredCount()==2 && restarted.team=="ABCDE12345" && restarted.jobs.isEmpty) + restarted.signOut();try await wait {!restarted.authenticated};assert(recoveryDefaults.string(forKey:"sideloadEmail")==nil) + restarted.recoverRememberedSessionIfNeeded();assert(!restarted.busy && restoredCount()==2);restarted.cancel() + recoveryDefaults.set("unavailable@example.invalid",forKey:"sideloadEmail") + let unavailable=SideloadModel(stateURL:root.appendingPathComponent("recovery/unavailable.json"),defaults:recoveryDefaults,helperURL:helper) + unavailable.recoverRememberedSessionIfNeeded();try await wait {!unavailable.busy && unavailable.error != nil} + assert(restoredCount()==3 && unavailable.hasRememberedSession && !unavailable.recoveringSession) + unavailable.recoverRememberedSessionIfNeeded();try await Task.sleep(nanoseconds:50_000_000);assert(restoredCount()==3) + unavailable.restoreSession();try await wait {!unavailable.busy};assert(restoredCount()==4 && unavailable.hasRememberedSession);unavailable.cancel() + let unavailableHelper=SideloadModel(stateURL:root.appendingPathComponent("recovery/missing.json"),defaults:recoveryDefaults,helperURL:root.appendingPathComponent("missing-helper")) + unavailableHelper.recoverRememberedSessionIfNeeded();assert(!unavailableHelper.busy && !unavailableHelper.recoveringSession && unavailableHelper.hasRememberedSession) + let recoveryOperations=try String(contentsOf:root.appendingPathComponent("operations.log"),encoding:.utf8) + assert(!recoveryOperations.split(separator:"\n").contains("install") && !recoveryOperations.split(separator:"\n").contains("login")) + recoveryDefaults.set("saved@example.invalid",forKey:"sideloadEmail") + let cancelledLogin=SideloadModel(stateURL:root.appendingPathComponent("recovery/cancelled-login.json"),defaults:recoveryDefaults,helperURL:helper) + cancelledLogin.signIn(password:"synthetic-password-only",remember:false);try await wait {cancelledLogin.codeRequired} + cancelledLogin.cancel();try await wait {!cancelledLogin.busy} + cancelledLogin.restoreSession();try await wait {cancelledLogin.authenticated && !cancelledLogin.busy} + assert(cancelledLogin.hasRememberedSession && recoveryDefaults.string(forKey:"sideloadEmail")=="saved@example.invalid") + cancelledLogin.cancel() 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 emptyScope=SideloadModel(stateURL:root.appendingPathComponent("empty-scope.json"),defaults:defaults,helperURL:helper) emptyScope.listDevices();try await wait {!emptyScope.busy && emptyScope.devices.count==1};assert(emptyScope.apps.isEmpty);emptyScope.cancel() diff --git a/desktop/release.json b/desktop/release.json index f7c4f8f..17232d7 100644 --- a/desktop/release.json +++ b/desktop/release.json @@ -1,5 +1,5 @@ { - "version": "1.14.2", - "build": 24, + "version": "1.14.3", + "build": 25, "minimumMacOSMajor": 26 } diff --git a/docs/agents/07-upstream-maintenance.md b/docs/agents/07-upstream-maintenance.md index 7ee95cf..4a13bb0 100644 --- a/docs/agents/07-upstream-maintenance.md +++ b/docs/agents/07-upstream-maintenance.md @@ -41,7 +41,8 @@ appear everywhere without being forgotten. No recurring schedule is implied. Follow the optional launcher-metadata contract; keep game settings delegated to the built app unless implementing that contract explicitly. Preserve the isideload exact-identifier patch, signed/profile identity checks, - Keychain session storage, redacted stdio and RomFS-only transfer capability. + Keychain session storage, one queued authentication-only remembered-session + recovery per launch, explicit logout and failure preservation, 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 diff --git a/docs/personal-ios.md b/docs/personal-ios.md index 8cc6bd1..5b25ce3 100644 --- a/docs/personal-ios.md +++ b/docs/personal-ios.md @@ -673,3 +673,23 @@ the modal frame and Close button. No helper, game or save behavior changed. Offscreen navigation validates that the open-catalog intent is consumed without an attached sheet; catalog fixtures/checks and the production SwiftUI build validate the change. No gameplay/device actions were performed. + +## Automatic remembered account recovery (1.14.3) + +The observed sign-in form was caused by missing normal-startup session recovery: +only a manual action or a due app refresh invoked the existing Keychain route. +This fix queues one authentication-only recovery when the remembered-account +marker exists, waits for the shared helper to be idle and prevents duplicate +attempts/retry loops. Failure preserves saved data and offers retry/password +choices without asserting that the token expired. A per-account previous team +is restored only against fresh team membership and never over an active refresh. +Explicit logout/cancel prevents queued recovery; cancelled remember-choice intent +cannot overwrite a prior saved preference during a later recovery. + +The native helper, Keychain service/key/format, game files and saves are unchanged. +No real Keychain value was read, credential printed, Apple login performed or +running launcher restarted. All 68 script cases completed with one optional JWT +dependency skip; expanded fake-helper recovery/cancellation fixtures passed again +after state cleanup. These test restoration orchestration, not the user's actual +session validity. Production compilation/signature validation and documentation +checks validate the delivered UI/client patch. diff --git a/docs/sideloading.md b/docs/sideloading.md index aede715..6e1cc4e 100644 --- a/docs/sideloading.md +++ b/docs/sideloading.md @@ -366,3 +366,43 @@ inventory even if signing later fails: registering happens before installation and can consume the slot independently of install success. The UI explains this before confirmation. Apple remains authoritative when registration is attempted. Agent validation never logs into a real account or registers a device. + +## Remembered session recovery (1.14.3) + +Remember session stores the helper's private Apple session data in macOS Keychain, +under the existing stable service/account key. It does not store the password or +copy Apple credentials to an iPhone/iPad, game folder or repository. The saved +email preference is only an opt-in marker, not proof that a Keychain entry exists +or that Apple still accepts it. No Keychain identifier/format or native helper +changed in this patch; actual token validity was not inspected by the agent. + +Previously a new launcher process started unauthenticated and restored only when +the player pressed Use Keychain session or a due installation refresh requested +it. The password form appeared first despite a remembered account. Startup and +the account view now request one automatic authentication-only recovery. It waits +for discovery or another helper operation to finish and does not create jobs, +register devices, sign, install or copy game data. Duplicate view/startup requests +coalesce; a failure does not cause a retry loop or delete the saved marker/token. +A visible recovery status offers cancellation outside the account/installer UI. + +On failure the account page offers Retry saved session and an explicit password +fallback. Connection/Keychain failures are not labelled as proven expiration. +Apple may still require a new authentication; the app cannot promise an indefinite +session. A manual retry remains possible. Cancelled password-login intent is +cleared before a later recovery, preventing an abandoned Do not remember choice +from deleting an earlier remembered account marker. Explicit sign-out remains +explicit: it removes the saved session through the existing helper and prevents +an automatic login from undoing the sign-out. + +The previously selected team is remembered per account only after successful +Apple authentication with Remember enabled. Recovery restores it only if that +exact ID is still in the freshly returned team list and there is no active +original-team refresh intent. It never chooses an arbitrary team. Existing +per-app account/team/BundleID bindings remain authoritative for updates. + +Compiled fake-helper fixtures simulate cold restart, deferred recovery after +scoped discovery, duplicate requests, no opt-in, successful prior-team recovery, +unavailable sessions, explicit retry, logout, helper absence and a cancelled +non-remembering login followed by recovery. They verify authentication-only +transport, no retry loop and preservation of saved preference metadata. They do +not access the user's Keychain or authenticate against Apple.