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).
|
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
|
## 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.
|
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).
|
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
|
## 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.
|
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 {
|
if model.running || model.failed || !model.activity.history.isEmpty {
|
||||||
Divider()
|
Divider()
|
||||||
BuildDock(model:model,show:{page = .prepare;model.showDetails=true})
|
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()}
|
if model.folder != nil && library.games.isEmpty && library.error==nil {remember()}
|
||||||
checkBuildAdvice()
|
checkBuildAdvice()
|
||||||
if !occupied && !library.games.isEmpty {sideload.listDevices()}
|
if !occupied && !library.games.isEmpty {sideload.listDevices()}
|
||||||
|
sideload.recoverRememberedSessionIfNeeded()
|
||||||
}
|
}
|
||||||
.dropDestination(for:URL.self) {items,_ in
|
.dropDestination(for:URL.self) {items,_ in
|
||||||
guard !occupied else {return false}
|
guard !occupied else {return false}
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ enum EasyLanguage: String, CaseIterable {
|
|||||||
|
|
||||||
struct EasyStrings {
|
struct EasyStrings {
|
||||||
static let english: [String:String] = [
|
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",
|
"Configuración":"Settings",
|
||||||
"General":"General",
|
"General":"General",
|
||||||
"Sección de configuración":"Settings section",
|
"Sección de configuración":"Settings section",
|
||||||
|
|||||||
@@ -113,12 +113,21 @@ enum RefreshPolicy {
|
|||||||
private var loggedPhase=""
|
private var loggedPhase=""
|
||||||
private var loggedBucket = -1
|
private var loggedBucket = -1
|
||||||
@Published var device=""
|
@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 existing=""
|
||||||
@Published var email=""
|
@Published var email=""
|
||||||
@Published var anisette="https://ani.iloader.app"
|
@Published var anisette="https://ani.iloader.app"
|
||||||
@Published var authenticated=false
|
@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 codeRequired=false
|
||||||
@Published var smsNumbers:[TrustedSMS]=[]
|
@Published var smsNumbers:[TrustedSMS]=[]
|
||||||
@Published var error:String?
|
@Published var error:String?
|
||||||
@@ -272,14 +281,20 @@ enum RefreshPolicy {
|
|||||||
let data=handle.availableData;if data.isEmpty {handle.readabilityHandler=nil;return}
|
let data=handle.availableData;if data.isEmpty {handle.readabilityHandler=nil;return}
|
||||||
Task {@MainActor in self?.receive(data)}
|
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()}
|
do {process=task;try task.run()}
|
||||||
catch {process=nil;input=nil;self.error="No pudimos iniciar el instalador nativo."}
|
catch {process=nil;input=nil;self.error="No pudimos iniciar el instalador nativo."}
|
||||||
}
|
}
|
||||||
private func send(_ value:[String:Any]) {
|
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)}
|
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 listDevices() {guard !busy else {return};error=nil;busy=true;startedAt=Date();fraction=nil;phase="Buscando dispositivos conectados…";record(phase);send(["op":"devices"])}
|
||||||
func inspectApps() {
|
func inspectApps() {
|
||||||
@@ -297,6 +312,7 @@ enum RefreshPolicy {
|
|||||||
|
|
||||||
func signIn(password:String,remember:Bool) {
|
func signIn(password:String,remember:Bool) {
|
||||||
guard !busy,!email.isEmpty,!password.isEmpty else {return}
|
guard !busy,!email.isEmpty,!password.isEmpty else {return}
|
||||||
|
recoveryRequested=false;automaticRecoveryAttempted=true;recoveringSession=false
|
||||||
email=email.trimmingCharacters(in:.whitespacesAndNewlines).lowercased()
|
email=email.trimmingCharacters(in:.whitespacesAndNewlines).lowercased()
|
||||||
busy=true;error=nil;startedAt=Date();fraction=nil;phase="Iniciando sesión con Apple…"
|
busy=true;error=nil;startedAt=Date();fraction=nil;phase="Iniciando sesión con Apple…"
|
||||||
record("Iniciando sesión con Apple…")
|
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 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 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 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 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() {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 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 {
|
var canUpdateAppOnly:Bool {
|
||||||
guard let folder=gameFolder,let version=PreviousPreparation.load(folder:folder,target:"ios")?.version,!version.isEmpty,
|
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}
|
let app=apps.first(where:{$0.id==existing}) else {return false}
|
||||||
@@ -375,13 +391,15 @@ enum RefreshPolicy {
|
|||||||
busy=false;record("Dispositivos registrados consultados.")
|
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 "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":
|
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."
|
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}
|
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)}
|
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 !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()
|
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)}
|
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":
|
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."]
|
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}
|
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=="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=="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=="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 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 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)}
|
if let error {record(error)}
|
||||||
activeRefresh=nil;installIntent=nil;refreshAuthenticated=false;codeRequired=false;busy=false
|
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)
|
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."}
|
} 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() {
|
func restoreSession() {
|
||||||
guard !busy,!email.isEmpty,defaults.string(forKey:"sideloadEmail")==email else {return}
|
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])
|
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}
|
var hasRememberedSession:Bool {defaults.string(forKey:"sideloadEmail")==email && !email.isEmpty}
|
||||||
func select(_ job:RefreshJob) {
|
func select(_ job:RefreshJob) {
|
||||||
guard !busy else {return}
|
guard !busy else {return}
|
||||||
@@ -469,7 +497,7 @@ enum RefreshPolicy {
|
|||||||
private func continueRefresh(_ job:RefreshJob) {
|
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 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}
|
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)}
|
else {startInstall(job,updating:true,content:nil)}
|
||||||
}
|
}
|
||||||
func setLoginItem(_ enabled:Bool) {
|
func setLoginItem(_ enabled:Bool) {
|
||||||
@@ -486,6 +514,7 @@ struct SideloadView:View {
|
|||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var approveRegistration=false
|
@State private var approveRegistration=false
|
||||||
@State private var installStep=0
|
@State private var installStep=0
|
||||||
|
@State private var usePassword=false
|
||||||
@State private var password=""
|
@State private var password=""
|
||||||
@State private var remember=true
|
@State private var remember=true
|
||||||
@State private var autoRefresh=true
|
@State private var autoRefresh=true
|
||||||
@@ -516,6 +545,15 @@ struct SideloadView:View {
|
|||||||
if accountOnly || installStep==1 {
|
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)
|
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.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("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)}
|
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}}
|
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)}
|
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)
|
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 {
|
} else if model.authenticated {
|
||||||
Label(text("Sesión iniciada"),systemImage:"person.crop.circle.badge.checkmark")
|
Label(text("Sesión iniciada"),systemImage:"person.crop.circle.badge.checkmark")
|
||||||
Text(model.email).font(.caption).textSelection(.enabled)
|
Text(model.email).font(.caption).textSelection(.enabled)
|
||||||
@@ -570,6 +609,7 @@ struct SideloadView:View {
|
|||||||
if !model.busy {Text(text(model.phase)).font(.callout)}
|
if !model.busy {Text(text(model.phase)).font(.callout)}
|
||||||
}.padding(24)
|
}.padding(24)
|
||||||
}
|
}
|
||||||
|
.task {model.recoverRememberedSessionIfNeeded()}
|
||||||
.onChange(of:model.device){_,_ in approveRegistration=false}
|
.onChange(of:model.device){_,_ in approveRegistration=false}
|
||||||
.onChange(of:installStep){_,step in if step==2 {approveRegistration=false;if !model.busy {model.inspectAccountDevices()}}}
|
.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()}}
|
.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 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 let export=model.exportedSaves {Button(text("Mostrar ZIP de partidas")){NSWorkspace.shared.activateFileViewerSelecting([export])}}
|
||||||
if !model.logs.isEmpty {
|
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()}
|
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)
|
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']})
|
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:
|
for line in sys.stdin:
|
||||||
value=json.loads(line)
|
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']}]})
|
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':
|
elif value['op']=='installed_apps':
|
||||||
assert value.get('original_ids') and 'known_ids' in value
|
assert value.get('original_ids') and 'known_ids' in value
|
||||||
@@ -26,7 +27,9 @@ import Foundation
|
|||||||
elif value['op']=='code':
|
elif value['op']=='code':
|
||||||
assert value['code']=='123456'
|
assert value['code']=='123456'
|
||||||
emit({'event':'signed_in','teams':[{'id':'ABCDE12345','name':'Personal test team'}]})
|
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':
|
elif value['op']=='install':
|
||||||
assert value['automatic']==False and value['update']==True
|
assert value['automatic']==False and value['update']==True
|
||||||
assert value.get('approve_registration') in (False,True)
|
assert value.get('approve_registration') in (False,True)
|
||||||
@@ -52,6 +55,44 @@ import Foundation
|
|||||||
elif value['op']=='quit':break
|
elif value['op']=='quit':break
|
||||||
""".write(to:helper,atomically:true,encoding:.utf8)
|
""".write(to:helper,atomically:true,encoding:.utf8)
|
||||||
try FileManager.default.setAttributes([.posixPermissions:0o700],ofItemAtPath:helper.path)
|
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 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)
|
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()
|
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",
|
"version": "1.14.3",
|
||||||
"build": 24,
|
"build": 25,
|
||||||
"minimumMacOSMajor": 26
|
"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
|
Follow the optional launcher-metadata contract; keep game settings delegated to
|
||||||
the built app unless implementing that contract explicitly.
|
the built app unless implementing that contract explicitly.
|
||||||
Preserve the isideload exact-identifier patch, signed/profile identity checks,
|
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
|
Preserve UDID deduplication (USB preferred), device metadata/search/update
|
||||||
selection, separate connected/registered inventories and logs without raw
|
selection, separate connected/registered inventories and logs without raw
|
||||||
credential/provider payloads. Full/light packaging must retain the bundled
|
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
|
Offscreen navigation validates that the open-catalog intent is consumed without
|
||||||
an attached sheet; catalog fixtures/checks and the production SwiftUI build
|
an attached sheet; catalog fixtures/checks and the production SwiftUI build
|
||||||
validate the change. No gameplay/device actions were performed.
|
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
|
and can consume the slot independently of install success. The UI explains this
|
||||||
before confirmation. Apple remains authoritative when registration is attempted.
|
before confirmation. Apple remains authoritative when registration is attempted.
|
||||||
Agent validation never logs into a real account or registers a device.
|
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