Restore remembered Apple sessions automatically without discarding saved access
This commit is contained in:
1 parent
69f95a1679
commit
34492fdb4d
10 files changed
+179
-16
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.14.2",
|
||||
"build": 24,
|
||||
"version": "1.14.3",
|
||||
"build": 25,
|
||||
"minimumMacOSMajor": 26
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user