Restore remembered Apple sessions automatically without discarding saved access

This commit is contained in:
Erick Sanchez committed 2026-10-27 15:48:01 -06:00
1 parent 69f95a1679
commit 34492fdb4d
10 files changed
+179 -16

No files matched your search

+2
View File
@@ -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.
+2
View File
@@ -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.
@@ -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}
+7
View File
@@ -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",
+52 -12
View File
@@ -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)
}
@@ -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()
+2 -2
View File
@@ -1,5 +1,5 @@
{
"version": "1.14.2",
"build": 24,
"version": "1.14.3",
"build": 25,
"minimumMacOSMajor": 26
}
+2 -1
View File
@@ -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
+20
View File
@@ -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.
+40
View File
@@ -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.