mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-08 17:19:10 -10:00
Merge pull request #4250 from martipops/feature/launcher-auth-status
🤖 Agent: launcher account status, profile heads, refresh and targeted sign-in (Microsoft only); rdbtCVS's points fixed earlier; main merged here (lang union, both sides' account tests, a string main dropped removed). Proven: union of main 956e35469 with 19 PRs, launcher clippy + tests green (the i18n tests after the string fix), game clippy/tests green.
This commit is contained in:
24 files changed
+2061
-104
No files matched your search
@@ -129,8 +129,30 @@ Sign in with a Microsoft account in your browser (device code). Play needs one t
|
||||
|
||||
horizon-launcher account sign-in
|
||||
|
||||
- `--for`: Sign this account (name or UUID) in again: another Microsoft account is refused and who plays stays the same
|
||||
|
||||
MCP: `account_sign_in`.
|
||||
|
||||
## `horizon-launcher account refresh`
|
||||
|
||||
Renew an account's session now, even when its access token is still fresh.
|
||||
|
||||
horizon-launcher account refresh <WHO>
|
||||
|
||||
- `WHO`
|
||||
|
||||
MCP: `account_refresh`.
|
||||
|
||||
## `horizon-launcher account session`
|
||||
|
||||
Renew an account's session for a game already running: prints the read-once session file it hands over (`--json`: {"session_file"} or {"authentication"} when it can't).
|
||||
|
||||
horizon-launcher account session <WHO>
|
||||
|
||||
- `WHO`
|
||||
|
||||
MCP: none: the game's own session handoff.
|
||||
|
||||
## `horizon-launcher account sign-out`
|
||||
|
||||
Sign out: the account's tokens leave the keychain.
|
||||
|
||||
@@ -140,6 +140,59 @@ one is selected. "Sign out" deletes the account's secrets from the keychain.
|
||||
- Tokens never appear in logs, errors, `horizon-launcher` CLI output, MCP results or crash reports.
|
||||
Requests are logged by endpoint name only.
|
||||
|
||||
## Account status and renewal
|
||||
|
||||
Settings shows each account's current authentication status. “Ready for online play” means
|
||||
its saved access token is not near expiry and its last ownership check succeeded; service
|
||||
revocation can only be discovered by a later request. “Needs refresh” means its access token
|
||||
has an hour or less left. On launcher startup these due sessions are renewed in the background;
|
||||
Play also keeps its existing ownership and renewal checks. Fresh tokens are not renewed on a
|
||||
startup status check.
|
||||
|
||||
**Refresh** explicitly renews an account even when its access token is fresh. Microsoft
|
||||
rejecting the refresh credential marks the account **Sign in again** until a successful browser
|
||||
sign-in replaces it. A network outage offers retry rather than discarding the account or
|
||||
claiming it requires a full login. Unreadable keychain data is never overwritten by refresh.
|
||||
If Microsoft rotates its refresh credential before Xbox/Minecraft fails, the rotated credential
|
||||
is still saved so another attempt can recover.
|
||||
|
||||
`horizon-launcher account refresh NAME_OR_UUID` exposes the same operation (MCP:
|
||||
`account_refresh`). `account list --json` includes public authentication status and token
|
||||
expiry, never tokens. `account refresh --json` returns a status object, including when renewal
|
||||
needs attention; callers inspect `state` rather than treating transport success as an online
|
||||
session. Inspection, refresh and sign-out run off the window's drawing thread. Credential
|
||||
operations are serialized in-process and between launchers using the same launcher data folder.
|
||||
|
||||
The reusable `account::session` API has no GPUI dependency. Keychain reads and browser sign-in
|
||||
remain owned by the launcher. A running game asks it for a renewed session through the CLI:
|
||||
the launcher passes its own path to the game as `HORIZON_LAUNCHER_EXE`, and
|
||||
`account session NAME_OR_UUID --json` renews that account and returns a new read-once session
|
||||
file (`{"session_file"}`), or the public status (`{"authentication"}`) when the account needs a
|
||||
browser sign-in or a retry. `account sign-in --for NAME_OR_UUID` is the targeted sign-in below,
|
||||
from the command line: it prints the link and code, refuses another Microsoft account, and
|
||||
leaves who plays unchanged. `account session` is not an MCP tool: its file holds a token, and
|
||||
only the game reads it. Renewing in the launcher does not change the token a running game
|
||||
already holds until the game asks.
|
||||
|
||||
### Profile heads, progress, and targeted re-sign-in
|
||||
|
||||
Microsoft accounts show their public Minecraft profile head in Settings, the selected-account
|
||||
chip, and the account switcher. Skins are fetched on workers and cached by their texture URL
|
||||
under `profile-heads/` in launcher data. Changing the skin invalidates that image; an unavailable
|
||||
image uses the UUID's default skin from installed resources, or a neutral placeholder when
|
||||
those resources are absent. The cache contains public images, never authentication tokens.
|
||||
|
||||
Account operations report the actual stage: waiting for account access, reading the keychain,
|
||||
renewing the Microsoft sign-in, Xbox Live, getting a Minecraft session, checking ownership,
|
||||
loading the profile, and saving. A browser flow separately says it is waiting for the player.
|
||||
Progress is public metadata and does not expose credentials through the UI or MCP.
|
||||
|
||||
**Sign in again** on an account row targets that exact Minecraft UUID. The browser instructions
|
||||
name the expected player. Signing in to a different account is rejected before storing its
|
||||
credentials or adding/selecting that identity. Retry keeps the same target; Cancel leaves the
|
||||
saved account alone. Successful re-sign-in restores that account without changing the player
|
||||
or offline name currently selected for Play. Adding a new account remains a separate action.
|
||||
|
||||
## Handing the session to the game
|
||||
|
||||
Vanilla's launcher passes `--accessToken <token>` on the command line, where every user of the
|
||||
|
||||
+27
-2
@@ -227,7 +227,6 @@
|
||||
"Saved: the next Play {what}.": "Gespeichert: Beim nächsten Spielen {what}.",
|
||||
"{count} playing now": "{count} spielen gerade",
|
||||
"Mods": "Mods",
|
||||
"Microsoft account · signed in": "Microsoft-Konto · angemeldet",
|
||||
"A shell command, run in the instance's folder. The game starts only if it succeeds.": "Ein Shell-Befehl, der im Ordner der Instanz ausgeführt wird. Das Spiel startet nur, wenn er erfolgreich ist.",
|
||||
"Host a world for your friends on this computer. Create one, press Start, and give them the address.": "Hoste eine Welt für deine Freunde auf diesem Computer. Erstelle einen Server, drücke Start und gib ihnen die Adresse.",
|
||||
"No ratings yet": "Noch keine Bewertungen",
|
||||
@@ -626,5 +625,31 @@
|
||||
"No version": "No version",
|
||||
"Signed in as {name}.": "Signed in as {name}.",
|
||||
"follows updates": "follows updates",
|
||||
"Search, then Enter": "Search, then Enter"
|
||||
"Search, then Enter": "Search, then Enter",
|
||||
"Waiting for account access…": "Warten auf Kontozugriff…",
|
||||
"Reading keychain…": "Schlüsselbund wird gelesen…",
|
||||
"Renewing Microsoft sign-in…": "Microsoft-Anmeldung wird erneuert…",
|
||||
"Signing in to Xbox Live…": "Anmeldung bei Xbox Live…",
|
||||
"Getting Minecraft session…": "Minecraft-Sitzung wird abgerufen…",
|
||||
"Checking Minecraft ownership…": "Minecraft-Besitz wird geprüft…",
|
||||
"Loading Minecraft profile…": "Minecraft-Profil wird geladen…",
|
||||
"Saving sign-in…": "Anmeldung wird gespeichert…",
|
||||
"Signing out…": "Abmeldung…",
|
||||
"Waiting for browser sign-in…": "Warten auf Anmeldung im Browser…",
|
||||
"Use the Microsoft account for {name}.": "Verwende das Microsoft-Konto von {name}.",
|
||||
"Sign in again as {name}": "Erneut als {name} anmelden",
|
||||
"Ready for online play": "Bereit für das Onlinespiel",
|
||||
"Needs refresh": "Aktualisierung erforderlich",
|
||||
"Sign in again": "Erneut anmelden",
|
||||
"Could not check sign-in": "Anmeldung konnte nicht geprüft werden",
|
||||
"Minecraft account needs attention": "Minecraft-Konto überprüfen",
|
||||
"Sign-in not checked": "Anmeldung nicht geprüft",
|
||||
"Refresh": "Aktualisieren",
|
||||
"Sign in again in your browser.": "Melde dich im Browser erneut an.",
|
||||
"Check Minecraft: Java Edition ownership and your profile at minecraft.net.": "Prüfe auf minecraft.net, ob du Minecraft: Java Edition besitzt, und dein Profil.",
|
||||
"Could not read the saved sign-in. Sign in again.": "Die gespeicherte Anmeldung konnte nicht gelesen werden. Melde dich erneut an.",
|
||||
"Could not read the saved sign-in.": "Die gespeicherte Anmeldung konnte nicht gelesen werden.",
|
||||
"Could not save the renewed sign-in.": "Die erneuerte Anmeldung konnte nicht gespeichert werden.",
|
||||
"The renewed sign-in could not be saved in the keychain. Unlock it and retry.": "Die erneuerte Anmeldung konnte nicht im Schlüsselbund gespeichert werden. Entsperre ihn und versuche es erneut.",
|
||||
"Another account operation is running. Try again in a moment.": "Ein anderer Kontovorgang läuft. Versuche es gleich noch einmal."
|
||||
}
|
||||
+27
-2
@@ -227,7 +227,6 @@
|
||||
"Saved: the next Play {what}.": "Guardado: la próxima vez que pulses Jugar {what}.",
|
||||
"{count} playing now": "{count} jugando ahora",
|
||||
"Mods": "Mods",
|
||||
"Microsoft account · signed in": "Cuenta de Microsoft · sesión iniciada",
|
||||
"A shell command, run in the instance's folder. The game starts only if it succeeds.": "Un comando de shell, ejecutado en la carpeta de la instancia. El juego solo se inicia si tiene éxito.",
|
||||
"Host a world for your friends on this computer. Create one, press Start, and give them the address.": "Aloja un mundo para tus amigos en este equipo. Crea uno, pulsa Iniciar y dales la dirección.",
|
||||
"No ratings yet": "Aún sin valoraciones",
|
||||
@@ -626,5 +625,31 @@
|
||||
"No version": "No version",
|
||||
"Signed in as {name}.": "Signed in as {name}.",
|
||||
"follows updates": "follows updates",
|
||||
"Search, then Enter": "Search, then Enter"
|
||||
"Search, then Enter": "Search, then Enter",
|
||||
"Waiting for account access…": "Esperando acceso a la cuenta…",
|
||||
"Reading keychain…": "Leyendo el llavero…",
|
||||
"Renewing Microsoft sign-in…": "Renovando la sesión de Microsoft…",
|
||||
"Signing in to Xbox Live…": "Iniciando sesión en Xbox Live…",
|
||||
"Getting Minecraft session…": "Obteniendo la sesión de Minecraft…",
|
||||
"Checking Minecraft ownership…": "Comprobando la propiedad de Minecraft…",
|
||||
"Loading Minecraft profile…": "Cargando el perfil de Minecraft…",
|
||||
"Saving sign-in…": "Guardando la sesión…",
|
||||
"Signing out…": "Cerrando sesión…",
|
||||
"Waiting for browser sign-in…": "Esperando el inicio de sesión en el navegador…",
|
||||
"Use the Microsoft account for {name}.": "Usa la cuenta de Microsoft de {name}.",
|
||||
"Sign in again as {name}": "Volver a iniciar sesión como {name}",
|
||||
"Ready for online play": "Listo para jugar en línea",
|
||||
"Needs refresh": "Necesita actualizarse",
|
||||
"Sign in again": "Volver a iniciar sesión",
|
||||
"Could not check sign-in": "No se pudo comprobar la sesión",
|
||||
"Minecraft account needs attention": "Revisa la cuenta de Minecraft",
|
||||
"Sign-in not checked": "Sesión sin comprobar",
|
||||
"Refresh": "Actualizar",
|
||||
"Sign in again in your browser.": "Vuelve a iniciar sesión en el navegador.",
|
||||
"Check Minecraft: Java Edition ownership and your profile at minecraft.net.": "Comprueba en minecraft.net que tienes Minecraft: Java Edition y tu perfil.",
|
||||
"Could not read the saved sign-in. Sign in again.": "No se pudo leer el inicio de sesión guardado. Vuelve a iniciar sesión.",
|
||||
"Could not read the saved sign-in.": "No se pudo leer el inicio de sesión guardado.",
|
||||
"Could not save the renewed sign-in.": "No se pudo guardar el inicio de sesión renovado.",
|
||||
"The renewed sign-in could not be saved in the keychain. Unlock it and retry.": "No se pudo guardar el inicio de sesión renovado en el llavero. Desbloquéalo y vuelve a intentarlo.",
|
||||
"Another account operation is running. Try again in a moment.": "Hay otra operación de cuenta en curso. Vuelve a intentarlo en un momento."
|
||||
}
|
||||
+27
-2
@@ -227,7 +227,6 @@
|
||||
"Saved: the next Play {what}.": "Enregistré : au prochain lancement {what}.",
|
||||
"{count} playing now": "{count} en jeu",
|
||||
"Mods": "Mods",
|
||||
"Microsoft account · signed in": "Compte Microsoft · connecté",
|
||||
"A shell command, run in the instance's folder. The game starts only if it succeeds.": "Une commande shell, exécutée dans le dossier de l'instance. Le jeu ne démarre que si elle réussit.",
|
||||
"Host a world for your friends on this computer. Create one, press Start, and give them the address.": "Hébergez un monde pour vos amis sur cet ordinateur. Créez-en un, appuyez sur Démarrer et donnez-leur l'adresse.",
|
||||
"No ratings yet": "Aucune note pour le moment",
|
||||
@@ -626,5 +625,31 @@
|
||||
"No version": "No version",
|
||||
"Signed in as {name}.": "Signed in as {name}.",
|
||||
"follows updates": "follows updates",
|
||||
"Search, then Enter": "Search, then Enter"
|
||||
"Search, then Enter": "Search, then Enter",
|
||||
"Waiting for account access…": "En attente de l’accès au compte…",
|
||||
"Reading keychain…": "Lecture du trousseau…",
|
||||
"Renewing Microsoft sign-in…": "Renouvellement de la connexion Microsoft…",
|
||||
"Signing in to Xbox Live…": "Connexion à Xbox Live…",
|
||||
"Getting Minecraft session…": "Obtention de la session Minecraft…",
|
||||
"Checking Minecraft ownership…": "Vérification de la possession de Minecraft…",
|
||||
"Loading Minecraft profile…": "Chargement du profil Minecraft…",
|
||||
"Saving sign-in…": "Enregistrement de la connexion…",
|
||||
"Signing out…": "Déconnexion…",
|
||||
"Waiting for browser sign-in…": "En attente de la connexion dans le navigateur…",
|
||||
"Use the Microsoft account for {name}.": "Utilisez le compte Microsoft de {name}.",
|
||||
"Sign in again as {name}": "Se reconnecter en tant que {name}",
|
||||
"Ready for online play": "Prêt pour le jeu en ligne",
|
||||
"Needs refresh": "Actualisation nécessaire",
|
||||
"Sign in again": "Se reconnecter",
|
||||
"Could not check sign-in": "Connexion impossible à vérifier",
|
||||
"Minecraft account needs attention": "Compte Minecraft à vérifier",
|
||||
"Sign-in not checked": "Connexion non vérifiée",
|
||||
"Refresh": "Actualiser",
|
||||
"Sign in again in your browser.": "Reconnectez-vous dans votre navigateur.",
|
||||
"Check Minecraft: Java Edition ownership and your profile at minecraft.net.": "Vérifiez la possession de Minecraft: Java Edition et votre profil sur minecraft.net.",
|
||||
"Could not read the saved sign-in. Sign in again.": "Impossible de lire la connexion enregistrée. Reconnectez-vous.",
|
||||
"Could not read the saved sign-in.": "Impossible de lire la connexion enregistrée.",
|
||||
"Could not save the renewed sign-in.": "Impossible d'enregistrer la connexion renouvelée.",
|
||||
"The renewed sign-in could not be saved in the keychain. Unlock it and retry.": "Impossible d'enregistrer la connexion renouvelée dans le trousseau. Déverrouillez-le et réessayez.",
|
||||
"Another account operation is running. Try again in a moment.": "Une autre opération de compte est en cours. Réessayez dans un instant."
|
||||
}
|
||||
+27
-2
@@ -51,7 +51,6 @@
|
||||
"Microsoft account": "Microsoft アカウント",
|
||||
"Another account": "別のアカウント",
|
||||
"Microsoft account · plays": "Microsoft アカウント · プレイ中",
|
||||
"Microsoft account · signed in": "Microsoft アカウント · サインイン済み",
|
||||
"Asking Microsoft…": "Microsoft に問い合わせ中…",
|
||||
"Add another account.": "別のアカウントを追加します。",
|
||||
"Copy code": "コードをコピー",
|
||||
@@ -626,5 +625,31 @@
|
||||
"No version": "バージョンなし",
|
||||
"Search, then Enter": "検索して Enter",
|
||||
"Signed in as {name}.": "{name} としてサインインしました。",
|
||||
"follows updates": "アップデートに追従"
|
||||
"follows updates": "アップデートに追従",
|
||||
"Waiting for account access…": "アカウントへのアクセスを待機中…",
|
||||
"Reading keychain…": "キーチェーンを読み込み中…",
|
||||
"Renewing Microsoft sign-in…": "Microsoftのサインインを更新中…",
|
||||
"Signing in to Xbox Live…": "Xbox Liveにサインイン中…",
|
||||
"Getting Minecraft session…": "Minecraftセッションを取得中…",
|
||||
"Checking Minecraft ownership…": "Minecraftの所有状況を確認中…",
|
||||
"Loading Minecraft profile…": "Minecraftプロフィールを読み込み中…",
|
||||
"Saving sign-in…": "サインイン情報を保存中…",
|
||||
"Signing out…": "サインアウト中…",
|
||||
"Waiting for browser sign-in…": "ブラウザでのサインインを待機中…",
|
||||
"Use the Microsoft account for {name}.": "{name} のMicrosoftアカウントを使用します。",
|
||||
"Sign in again as {name}": "{name} として再度サインイン",
|
||||
"Ready for online play": "オンラインプレイの準備完了",
|
||||
"Needs refresh": "更新が必要",
|
||||
"Sign in again": "再度サインイン",
|
||||
"Could not check sign-in": "サインインを確認できませんでした",
|
||||
"Minecraft account needs attention": "Minecraftアカウントの確認が必要です",
|
||||
"Sign-in not checked": "サインイン未確認",
|
||||
"Refresh": "更新",
|
||||
"Sign in again in your browser.": "ブラウザで再度サインインしてください。",
|
||||
"Check Minecraft: Java Edition ownership and your profile at minecraft.net.": "minecraft.netでMinecraft: Java Editionの所有状況とプロフィールを確認してください。",
|
||||
"Could not read the saved sign-in. Sign in again.": "保存されたサインイン情報を読み込めませんでした。もう一度サインインしてください。",
|
||||
"Could not read the saved sign-in.": "保存されたサインイン情報を読み込めませんでした。",
|
||||
"Could not save the renewed sign-in.": "更新したサインイン情報を保存できませんでした。",
|
||||
"The renewed sign-in could not be saved in the keychain. Unlock it and retry.": "更新したサインイン情報をキーチェーンに保存できませんでした。ロックを解除して再試行してください。",
|
||||
"Another account operation is running. Try again in a moment.": "別のアカウント操作を実行中です。しばらくしてからもう一度お試しください。"
|
||||
}
|
||||
+27
-2
@@ -227,7 +227,6 @@
|
||||
"Saved: the next Play {what}.": "Zapisano: przy następnym uruchomieniu {what}.",
|
||||
"{count} playing now": "{count} gra teraz",
|
||||
"Mods": "Mody",
|
||||
"Microsoft account · signed in": "Konto Microsoft · zalogowano",
|
||||
"A shell command, run in the instance's folder. The game starts only if it succeeds.": "Polecenie powłoki uruchamiane w folderze instancji. Gra uruchomi się tylko wtedy, gdy zakończy się powodzeniem.",
|
||||
"Host a world for your friends on this computer. Create one, press Start, and give them the address.": "Hostuj świat dla znajomych na tym komputerze. Utwórz serwer, naciśnij Start i podaj im adres.",
|
||||
"No ratings yet": "Brak ocen",
|
||||
@@ -626,5 +625,31 @@
|
||||
"No version": "No version",
|
||||
"Signed in as {name}.": "Signed in as {name}.",
|
||||
"follows updates": "follows updates",
|
||||
"Search, then Enter": "Search, then Enter"
|
||||
"Search, then Enter": "Search, then Enter",
|
||||
"Waiting for account access…": "Oczekiwanie na dostęp do konta…",
|
||||
"Reading keychain…": "Odczytywanie pęku kluczy…",
|
||||
"Renewing Microsoft sign-in…": "Odnawianie logowania Microsoft…",
|
||||
"Signing in to Xbox Live…": "Logowanie do Xbox Live…",
|
||||
"Getting Minecraft session…": "Pobieranie sesji Minecraft…",
|
||||
"Checking Minecraft ownership…": "Sprawdzanie własności Minecraft…",
|
||||
"Loading Minecraft profile…": "Wczytywanie profilu Minecraft…",
|
||||
"Saving sign-in…": "Zapisywanie logowania…",
|
||||
"Signing out…": "Wylogowywanie…",
|
||||
"Waiting for browser sign-in…": "Oczekiwanie na logowanie w przeglądarce…",
|
||||
"Use the Microsoft account for {name}.": "Użyj konta Microsoft gracza {name}.",
|
||||
"Sign in again as {name}": "Zaloguj się ponownie jako {name}",
|
||||
"Ready for online play": "Gotowe do gry online",
|
||||
"Needs refresh": "Wymaga odświeżenia",
|
||||
"Sign in again": "Zaloguj się ponownie",
|
||||
"Could not check sign-in": "Nie można sprawdzić logowania",
|
||||
"Minecraft account needs attention": "Sprawdź konto Minecraft",
|
||||
"Sign-in not checked": "Logowanie niesprawdzone",
|
||||
"Refresh": "Odśwież",
|
||||
"Sign in again in your browser.": "Zaloguj się ponownie w przeglądarce.",
|
||||
"Check Minecraft: Java Edition ownership and your profile at minecraft.net.": "Sprawdź na minecraft.net posiadanie Minecraft: Java Edition i swój profil.",
|
||||
"Could not read the saved sign-in. Sign in again.": "Nie można odczytać zapisanego logowania. Zaloguj się ponownie.",
|
||||
"Could not read the saved sign-in.": "Nie można odczytać zapisanego logowania.",
|
||||
"Could not save the renewed sign-in.": "Nie można zapisać odnowionego logowania.",
|
||||
"The renewed sign-in could not be saved in the keychain. Unlock it and retry.": "Nie można zapisać odnowionego logowania w pęku kluczy. Odblokuj go i spróbuj ponownie.",
|
||||
"Another account operation is running. Try again in a moment.": "Trwa inna operacja na koncie. Spróbuj ponownie za chwilę."
|
||||
}
|
||||
@@ -51,7 +51,6 @@
|
||||
"Microsoft account": "Conta Microsoft",
|
||||
"Another account": "Outra conta",
|
||||
"Microsoft account · plays": "Conta Microsoft · jogando",
|
||||
"Microsoft account · signed in": "Conta Microsoft · conectada",
|
||||
"Asking Microsoft…": "Conectando à Microsoft…",
|
||||
"Add another account.": "Adicionar outra conta.",
|
||||
"Copy code": "Copiar código",
|
||||
@@ -626,5 +625,31 @@
|
||||
"No version": "Nenhuma versão",
|
||||
"Search, then Enter": "Pesquise e aperte Enter",
|
||||
"Signed in as {name}.": "Conectado como {name}.",
|
||||
"follows updates": "segue as atualizações"
|
||||
"follows updates": "segue as atualizações",
|
||||
"Waiting for account access…": "Aguardando acesso à conta…",
|
||||
"Reading keychain…": "Lendo o chaveiro…",
|
||||
"Renewing Microsoft sign-in…": "Renovando o login da Microsoft…",
|
||||
"Signing in to Xbox Live…": "Entrando no Xbox Live…",
|
||||
"Getting Minecraft session…": "Obtendo a sessão do Minecraft…",
|
||||
"Checking Minecraft ownership…": "Verificando a posse do Minecraft…",
|
||||
"Loading Minecraft profile…": "Carregando o perfil do Minecraft…",
|
||||
"Saving sign-in…": "Salvando o login…",
|
||||
"Signing out…": "Saindo da conta…",
|
||||
"Waiting for browser sign-in…": "Aguardando o login no navegador…",
|
||||
"Use the Microsoft account for {name}.": "Use a conta Microsoft de {name}.",
|
||||
"Sign in again as {name}": "Entrar novamente como {name}",
|
||||
"Ready for online play": "Pronto para jogar online",
|
||||
"Needs refresh": "Precisa ser atualizado",
|
||||
"Sign in again": "Entrar novamente",
|
||||
"Could not check sign-in": "Não foi possível verificar o login",
|
||||
"Minecraft account needs attention": "A conta do Minecraft precisa de atenção",
|
||||
"Sign-in not checked": "Login não verificado",
|
||||
"Refresh": "Atualizar",
|
||||
"Sign in again in your browser.": "Entre novamente pelo navegador.",
|
||||
"Check Minecraft: Java Edition ownership and your profile at minecraft.net.": "Verifique em minecraft.net se você possui o Minecraft: Java Edition e confira seu perfil.",
|
||||
"Could not read the saved sign-in. Sign in again.": "Não foi possível ler o login salvo. Entre novamente.",
|
||||
"Could not read the saved sign-in.": "Não foi possível ler o login salvo.",
|
||||
"Could not save the renewed sign-in.": "Não foi possível salvar o login renovado.",
|
||||
"The renewed sign-in could not be saved in the keychain. Unlock it and retry.": "Não foi possível salvar o login renovado no chaveiro. Desbloqueie-o e tente novamente.",
|
||||
"Another account operation is running. Try again in a moment.": "Outra operação de conta está em andamento. Tente novamente em instantes."
|
||||
}
|
||||
+27
-2
@@ -227,7 +227,6 @@
|
||||
"Saved: the next Play {what}.": "Сохранено: при следующем запуске {what}.",
|
||||
"{count} playing now": "{count} сейчас в игре",
|
||||
"Mods": "Моды",
|
||||
"Microsoft account · signed in": "Аккаунт Microsoft · вход выполнен",
|
||||
"A shell command, run in the instance's folder. The game starts only if it succeeds.": "Команда оболочки, выполняемая в папке сборки. Игра запустится только при успешном выполнении.",
|
||||
"Host a world for your friends on this computer. Create one, press Start, and give them the address.": "Запустите мир для друзей на этом компьютере. Создайте сервер, нажмите «Запустить» и дайте им адрес.",
|
||||
"No ratings yet": "Оценок пока нет",
|
||||
@@ -626,5 +625,31 @@
|
||||
"No version": "Нет версии",
|
||||
"Signed in as {name}.": "Вы вошли как {name}.",
|
||||
"follows updates": "следует за обновлениями",
|
||||
"Search, then Enter": "Поиск, затем Enter"
|
||||
"Search, then Enter": "Поиск, затем Enter",
|
||||
"Waiting for account access…": "Ожидание доступа к учётной записи…",
|
||||
"Reading keychain…": "Чтение связки ключей…",
|
||||
"Renewing Microsoft sign-in…": "Обновление входа Microsoft…",
|
||||
"Signing in to Xbox Live…": "Вход в Xbox Live…",
|
||||
"Getting Minecraft session…": "Получение сеанса Minecraft…",
|
||||
"Checking Minecraft ownership…": "Проверка владения Minecraft…",
|
||||
"Loading Minecraft profile…": "Загрузка профиля Minecraft…",
|
||||
"Saving sign-in…": "Сохранение входа…",
|
||||
"Signing out…": "Выход…",
|
||||
"Waiting for browser sign-in…": "Ожидание входа в браузере…",
|
||||
"Use the Microsoft account for {name}.": "Используйте учётную запись Microsoft для {name}.",
|
||||
"Sign in again as {name}": "Войти снова как {name}",
|
||||
"Ready for online play": "Готово к игре по сети",
|
||||
"Needs refresh": "Нужно обновить сеанс",
|
||||
"Sign in again": "Войти снова",
|
||||
"Could not check sign-in": "Не удалось проверить вход",
|
||||
"Minecraft account needs attention": "Проверьте учётную запись Minecraft",
|
||||
"Sign-in not checked": "Вход не проверен",
|
||||
"Refresh": "Обновить",
|
||||
"Sign in again in your browser.": "Войдите снова в браузере.",
|
||||
"Check Minecraft: Java Edition ownership and your profile at minecraft.net.": "Проверьте владение Minecraft: Java Edition и свой профиль на minecraft.net.",
|
||||
"Could not read the saved sign-in. Sign in again.": "Не удалось прочитать сохранённый вход. Войдите снова.",
|
||||
"Could not read the saved sign-in.": "Не удалось прочитать сохранённый вход.",
|
||||
"Could not save the renewed sign-in.": "Не удалось сохранить обновлённый вход.",
|
||||
"The renewed sign-in could not be saved in the keychain. Unlock it and retry.": "Не удалось сохранить обновлённый вход в связке ключей. Разблокируйте её и повторите.",
|
||||
"Another account operation is running. Try again in a moment.": "Выполняется другая операция с аккаунтом. Повторите через минуту."
|
||||
}
|
||||
+27
-2
@@ -51,7 +51,6 @@
|
||||
"Microsoft account": "Microsoft hesabı",
|
||||
"Another account": "Başka bir hesap",
|
||||
"Microsoft account · plays": "Microsoft hesabı · oynuyor",
|
||||
"Microsoft account · signed in": "Microsoft hesabı · oturum açıldı",
|
||||
"Asking Microsoft…": "Microsoft'a soruluyor…",
|
||||
"Add another account.": "Başka bir hesap ekle.",
|
||||
"Copy code": "Kodu kopyala",
|
||||
@@ -626,5 +625,31 @@
|
||||
"No version": "No version",
|
||||
"Search, then Enter": "Search, then Enter",
|
||||
"Signed in as {name}.": "Signed in as {name}.",
|
||||
"follows updates": "follows updates"
|
||||
"follows updates": "follows updates",
|
||||
"Waiting for account access…": "Hesap erişimi bekleniyor…",
|
||||
"Reading keychain…": "Anahtar zinciri okunuyor…",
|
||||
"Renewing Microsoft sign-in…": "Microsoft oturumu yenileniyor…",
|
||||
"Signing in to Xbox Live…": "Xbox Live'da oturum açılıyor…",
|
||||
"Getting Minecraft session…": "Minecraft oturumu alınıyor…",
|
||||
"Checking Minecraft ownership…": "Minecraft sahipliği kontrol ediliyor…",
|
||||
"Loading Minecraft profile…": "Minecraft profili yükleniyor…",
|
||||
"Saving sign-in…": "Oturum kaydediliyor…",
|
||||
"Signing out…": "Oturum kapatılıyor…",
|
||||
"Waiting for browser sign-in…": "Tarayıcıda oturum açılması bekleniyor…",
|
||||
"Use the Microsoft account for {name}.": "{name} için Microsoft hesabını kullan.",
|
||||
"Sign in again as {name}": "{name} olarak tekrar oturum aç",
|
||||
"Ready for online play": "Çevrimiçi oyuna hazır",
|
||||
"Needs refresh": "Yenilenmesi gerekiyor",
|
||||
"Sign in again": "Tekrar oturum aç",
|
||||
"Could not check sign-in": "Oturum kontrol edilemedi",
|
||||
"Minecraft account needs attention": "Minecraft hesabı ilgi gerektiriyor",
|
||||
"Sign-in not checked": "Oturum kontrol edilmedi",
|
||||
"Refresh": "Yenile",
|
||||
"Sign in again in your browser.": "Tarayıcınızda tekrar oturum açın.",
|
||||
"Check Minecraft: Java Edition ownership and your profile at minecraft.net.": "Minecraft: Java Edition sahipliğinizi ve profilinizi minecraft.net adresinde kontrol edin.",
|
||||
"Could not read the saved sign-in. Sign in again.": "Kaydedilen oturum okunamadı. Tekrar oturum açın.",
|
||||
"Could not read the saved sign-in.": "Kaydedilen oturum okunamadı.",
|
||||
"Could not save the renewed sign-in.": "Yenilenen oturum kaydedilemedi.",
|
||||
"The renewed sign-in could not be saved in the keychain. Unlock it and retry.": "Yenilenen oturum anahtar zincirine kaydedilemedi. Kilidini açıp tekrar deneyin.",
|
||||
"Another account operation is running. Try again in a moment.": "Başka bir hesap işlemi sürüyor. Birazdan tekrar deneyin."
|
||||
}
|
||||
+27
-2
@@ -227,7 +227,6 @@
|
||||
"Saved: the next Play {what}.": "Збережено: під час наступного запуску {what}.",
|
||||
"{count} playing now": "{count} зараз у грі",
|
||||
"Mods": "Моди",
|
||||
"Microsoft account · signed in": "Обліковий запис Microsoft · вхід виконано",
|
||||
"A shell command, run in the instance's folder. The game starts only if it succeeds.": "Команда оболонки, що виконується в папці збірки. Гра запуститься лише в разі успішного виконання.",
|
||||
"Host a world for your friends on this computer. Create one, press Start, and give them the address.": "Запустіть світ для друзів на цьому комп'ютері. Створіть сервер, натисніть «Запустити» і дайте їм адресу.",
|
||||
"No ratings yet": "Оцінок ще немає",
|
||||
@@ -626,5 +625,31 @@
|
||||
"No version": "No version",
|
||||
"Signed in as {name}.": "Signed in as {name}.",
|
||||
"follows updates": "follows updates",
|
||||
"Search, then Enter": "Search, then Enter"
|
||||
"Search, then Enter": "Search, then Enter",
|
||||
"Waiting for account access…": "Очікування доступу до облікового запису…",
|
||||
"Reading keychain…": "Читання в’язки ключів…",
|
||||
"Renewing Microsoft sign-in…": "Оновлення входу Microsoft…",
|
||||
"Signing in to Xbox Live…": "Вхід в Xbox Live…",
|
||||
"Getting Minecraft session…": "Отримання сеансу Minecraft…",
|
||||
"Checking Minecraft ownership…": "Перевірка володіння Minecraft…",
|
||||
"Loading Minecraft profile…": "Завантаження профілю Minecraft…",
|
||||
"Saving sign-in…": "Збереження входу…",
|
||||
"Signing out…": "Вихід…",
|
||||
"Waiting for browser sign-in…": "Очікування входу в браузері…",
|
||||
"Use the Microsoft account for {name}.": "Використайте обліковий запис Microsoft для {name}.",
|
||||
"Sign in again as {name}": "Увійти знову як {name}",
|
||||
"Ready for online play": "Готово до гри в мережі",
|
||||
"Needs refresh": "Потрібно оновити сеанс",
|
||||
"Sign in again": "Увійти знову",
|
||||
"Could not check sign-in": "Не вдалося перевірити вхід",
|
||||
"Minecraft account needs attention": "Перевірте обліковий запис Minecraft",
|
||||
"Sign-in not checked": "Вхід не перевірено",
|
||||
"Refresh": "Оновити",
|
||||
"Sign in again in your browser.": "Увійдіть знову в браузері.",
|
||||
"Check Minecraft: Java Edition ownership and your profile at minecraft.net.": "Перевірте володіння Minecraft: Java Edition і свій профіль на minecraft.net.",
|
||||
"Could not read the saved sign-in. Sign in again.": "Не вдалося прочитати збережений вхід. Увійдіть знову.",
|
||||
"Could not read the saved sign-in.": "Не вдалося прочитати збережений вхід.",
|
||||
"Could not save the renewed sign-in.": "Не вдалося зберегти оновлений вхід.",
|
||||
"The renewed sign-in could not be saved in the keychain. Unlock it and retry.": "Не вдалося зберегти оновлений вхід у в'язці ключів. Розблокуйте її та повторіть.",
|
||||
"Another account operation is running. Try again in a moment.": "Виконується інша операція з обліковим записом. Спробуйте ще раз за мить."
|
||||
}
|
||||
+27
-2
@@ -227,7 +227,6 @@
|
||||
"Saved: the next Play {what}.": "已保存:下次点击“开始游戏”时 {what}。",
|
||||
"{count} playing now": "{count} 人正在游玩",
|
||||
"Mods": "模组",
|
||||
"Microsoft account · signed in": "Microsoft 账户 · 已登录",
|
||||
"A shell command, run in the instance's folder. The game starts only if it succeeds.": "在实例文件夹中运行的 shell 命令。仅当命令成功时游戏才会启动。",
|
||||
"Host a world for your friends on this computer. Create one, press Start, and give them the address.": "在此计算机上为好友托管世界。创建一个服务器,点击“启动”,然后将地址发给他们。",
|
||||
"No ratings yet": "暂无评分",
|
||||
@@ -626,5 +625,31 @@
|
||||
"No version": "No version",
|
||||
"Signed in as {name}.": "Signed in as {name}.",
|
||||
"follows updates": "follows updates",
|
||||
"Search, then Enter": "Search, then Enter"
|
||||
"Search, then Enter": "Search, then Enter",
|
||||
"Waiting for account access…": "正在等待访问账户…",
|
||||
"Reading keychain…": "正在读取钥匙串…",
|
||||
"Renewing Microsoft sign-in…": "正在续期 Microsoft 登录…",
|
||||
"Signing in to Xbox Live…": "正在登录 Xbox Live…",
|
||||
"Getting Minecraft session…": "正在获取 Minecraft 会话…",
|
||||
"Checking Minecraft ownership…": "正在检查 Minecraft 所有权…",
|
||||
"Loading Minecraft profile…": "正在加载 Minecraft 档案…",
|
||||
"Saving sign-in…": "正在保存登录信息…",
|
||||
"Signing out…": "正在退出登录…",
|
||||
"Waiting for browser sign-in…": "正在等待浏览器登录…",
|
||||
"Use the Microsoft account for {name}.": "请使用 {name} 的 Microsoft 账户。",
|
||||
"Sign in again as {name}": "重新以 {name} 登录",
|
||||
"Ready for online play": "可以在线游玩",
|
||||
"Needs refresh": "需要刷新",
|
||||
"Sign in again": "重新登录",
|
||||
"Could not check sign-in": "无法检查登录状态",
|
||||
"Minecraft account needs attention": "请检查 Minecraft 账户",
|
||||
"Sign-in not checked": "尚未检查登录状态",
|
||||
"Refresh": "刷新",
|
||||
"Sign in again in your browser.": "请在浏览器中重新登录。",
|
||||
"Check Minecraft: Java Edition ownership and your profile at minecraft.net.": "请在 minecraft.net 检查 Minecraft: Java Edition 的所有权和你的档案。",
|
||||
"Could not read the saved sign-in. Sign in again.": "无法读取已保存的登录信息。请重新登录。",
|
||||
"Could not read the saved sign-in.": "无法读取已保存的登录信息。",
|
||||
"Could not save the renewed sign-in.": "无法保存续期后的登录信息。",
|
||||
"The renewed sign-in could not be saved in the keychain. Unlock it and retry.": "无法将续期后的登录信息保存到钥匙串。请解锁后重试。",
|
||||
"Another account operation is running. Try again in a moment.": "另一个账户操作正在进行。请稍后重试。"
|
||||
}
|
||||
+166
-23
@@ -11,6 +11,8 @@
|
||||
//!
|
||||
//! The Azure app (client ID) is Horizon's own and is configuration: `HORIZON_MSA_CLIENT_ID` at
|
||||
//! build time, or at run time to override it. Without one, sign-in says it is not configured.
|
||||
pub mod session;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -20,8 +22,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
/// The keychain service name every secret is stored under.
|
||||
pub const SERVICE: &str = "dev.playhorizon.launcher";
|
||||
const SCOPE: &str = "XboxLive.signin offline_access";
|
||||
pub const NOT_CONFIGURED: &str =
|
||||
"This build of the launcher has no Microsoft sign-in (HORIZON_MSA_CLIENT_ID), so it can't start the game.";
|
||||
pub const NOT_CONFIGURED: &str = "This build of the launcher has no Microsoft sign-in (HORIZON_MSA_CLIENT_ID), so it can't start the game.";
|
||||
/// What every Play says while no account in the launcher owns the game.
|
||||
pub const NEEDS_OWNER: &str =
|
||||
"Sign in with a Microsoft account that owns Minecraft: Java Edition to play";
|
||||
@@ -99,7 +100,7 @@ fn send(request: ureq::Request, body: Option<Body>, service: &str) -> Result<(u1
|
||||
Err(ureq::Error::Transport(_)) => {
|
||||
return Err(format!(
|
||||
"Could not reach {service}. Check your internet connection."
|
||||
))
|
||||
));
|
||||
}
|
||||
};
|
||||
let status = response.status();
|
||||
@@ -125,7 +126,7 @@ fn now() -> u64 {
|
||||
// ----- The device-code flow ----------------------------------------------------------------------
|
||||
|
||||
/// What the player types at the verification page.
|
||||
#[derive(Clone)]
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct DeviceCode {
|
||||
pub user_code: String,
|
||||
pub verification_uri: String,
|
||||
@@ -297,7 +298,12 @@ struct Minecraft {
|
||||
xuid: Option<String>,
|
||||
}
|
||||
|
||||
fn minecraft_login(ep: &Endpoints, ms_access: &str) -> Result<Minecraft, String> {
|
||||
fn minecraft_login(
|
||||
ep: &Endpoints,
|
||||
ms_access: &str,
|
||||
progress: &dyn Fn(session::Phase),
|
||||
) -> Result<Minecraft, String> {
|
||||
progress(session::Phase::Xbox);
|
||||
let (status, xbl) = send(
|
||||
request_to("POST", &ep.xbox_user),
|
||||
Some(Body::Json(json!({
|
||||
@@ -334,6 +340,7 @@ fn minecraft_login(ep: &Endpoints, ms_access: &str) -> Result<Minecraft, String>
|
||||
return Err("Xbox Live refused the sign-in.".into());
|
||||
};
|
||||
let xuid = claims.and_then(|c| str_field(c, "xid"));
|
||||
progress(session::Phase::Minecraft);
|
||||
let (token, expires_in) = minecraft_token(ep, &format!("XBL3.0 x={uhs};{xsts_token}"))?;
|
||||
Ok(Minecraft {
|
||||
token,
|
||||
@@ -467,6 +474,8 @@ fn profile(ep: &Endpoints, token: &str) -> Result<Option<Account>, String> {
|
||||
#[derive(Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Secrets {
|
||||
refresh_token: String,
|
||||
#[serde(default)]
|
||||
sign_in_required: bool,
|
||||
minecraft_token: String,
|
||||
minecraft_expires_at: u64,
|
||||
#[serde(default)]
|
||||
@@ -488,10 +497,20 @@ impl std::fmt::Debug for Secrets {
|
||||
|
||||
/// From Microsoft tokens to a ready account: Xbox, XSTS, Minecraft, ownership, profile.
|
||||
pub fn finish_sign_in(ep: &Endpoints, ms: MsTokens) -> Result<(Account, Secrets), String> {
|
||||
let mc = minecraft_login(ep, &ms.access)?;
|
||||
finish_sign_in_with_progress(ep, ms, &|_| {})
|
||||
}
|
||||
|
||||
pub fn finish_sign_in_with_progress(
|
||||
ep: &Endpoints,
|
||||
ms: MsTokens,
|
||||
progress: &dyn Fn(session::Phase),
|
||||
) -> Result<(Account, Secrets), String> {
|
||||
let mc = minecraft_login(ep, &ms.access, progress)?;
|
||||
progress(session::Phase::Ownership);
|
||||
if !owns_game(ep, &mc.token)? {
|
||||
return Err(DOES_NOT_OWN.into());
|
||||
}
|
||||
progress(session::Phase::Profile);
|
||||
let account = profile(ep, &mc.token)?.ok_or(NO_PROFILE)?;
|
||||
Ok((account, Secrets::new(ms.refresh, mc)))
|
||||
}
|
||||
@@ -500,6 +519,7 @@ impl Secrets {
|
||||
fn new(refresh_token: String, mc: Minecraft) -> Self {
|
||||
Self {
|
||||
refresh_token,
|
||||
sign_in_required: false,
|
||||
minecraft_token: mc.token,
|
||||
minecraft_expires_at: mc.expires_at,
|
||||
xuid: mc.xuid,
|
||||
@@ -521,30 +541,65 @@ enum Check {
|
||||
/// Asks whether `account` owns the game, with a Minecraft token renewed through the refresh token
|
||||
/// first when it has less than an hour left. The secrets come back renewed whatever the answer.
|
||||
fn check(ep: &Endpoints, client_id: Option<&str>, account: &Account, secrets: Secrets) -> Check {
|
||||
let mut secrets = secrets;
|
||||
if secrets.minecraft_expires_at <= now() + RENEW_BEFORE {
|
||||
check_with(ep, client_id, account, secrets, false, &|_| {})
|
||||
}
|
||||
|
||||
fn check_with(
|
||||
ep: &Endpoints,
|
||||
client_id: Option<&str>,
|
||||
account: &Account,
|
||||
mut secrets: Secrets,
|
||||
force: bool,
|
||||
progress: &dyn Fn(session::Phase),
|
||||
) -> Check {
|
||||
if secrets.sign_in_required {
|
||||
return Check::NoAnswer(SIGN_IN_AGAIN.into(), secrets);
|
||||
}
|
||||
if force || secrets.minecraft_expires_at <= now() + RENEW_BEFORE {
|
||||
progress(session::Phase::Microsoft);
|
||||
let renewed = client_id
|
||||
.ok_or_else(|| NOT_CONFIGURED.to_string())
|
||||
.and_then(|id| refresh_ms(ep, id, &secrets.refresh_token))
|
||||
.and_then(|ms| Ok((minecraft_login(ep, &ms.access)?, ms.refresh)));
|
||||
match renewed {
|
||||
Ok((mc, refresh)) => {
|
||||
.and_then(|id| refresh_ms(ep, id, &secrets.refresh_token));
|
||||
let ms = match renewed {
|
||||
Ok(ms) => ms,
|
||||
Err(e) => {
|
||||
secrets.sign_in_required = e == SIGN_IN_AGAIN;
|
||||
return Check::NoAnswer(e, secrets);
|
||||
}
|
||||
};
|
||||
// Microsoft can rotate its refresh credential even if a later service fails.
|
||||
secrets.refresh_token = ms.refresh;
|
||||
match minecraft_login(ep, &ms.access, progress) {
|
||||
Ok(mc) => {
|
||||
secrets = Secrets {
|
||||
owns: secrets.owns,
|
||||
..Secrets::new(refresh, mc)
|
||||
..Secrets::new(secrets.refresh_token, mc)
|
||||
}
|
||||
}
|
||||
Err(e) => return Check::NoAnswer(e, secrets),
|
||||
}
|
||||
}
|
||||
let token = &secrets.minecraft_token;
|
||||
match owns_game(ep, token).and_then(|owns| Ok(if owns { profile(ep, token)? } else { None })) {
|
||||
progress(session::Phase::Ownership);
|
||||
match owns_game(ep, token).and_then(|owns| {
|
||||
if !owns {
|
||||
return Ok(None);
|
||||
}
|
||||
progress(session::Phase::Profile);
|
||||
profile(ep, token)
|
||||
}) {
|
||||
Err(e) => Check::NoAnswer(e, secrets),
|
||||
Ok(None) => Check::DoesNot(Secrets {
|
||||
owns: false,
|
||||
..secrets
|
||||
}),
|
||||
Ok(Some(p)) if p.uuid != account.uuid => Check::NoAnswer(OTHER_PROFILE.into(), secrets),
|
||||
Ok(Some(p)) if p.uuid != account.uuid => Check::NoAnswer(
|
||||
OTHER_PROFILE.into(),
|
||||
Secrets {
|
||||
sign_in_required: true,
|
||||
..secrets
|
||||
},
|
||||
),
|
||||
Ok(Some(p)) => Check::Owns(
|
||||
p,
|
||||
Secrets {
|
||||
@@ -564,6 +619,10 @@ pub trait SecretStore: Send + Sync {
|
||||
fn delete(&self, key: &str) -> Result<(), String>;
|
||||
/// False for memory: secrets are gone when the launcher closes.
|
||||
fn persistent(&self) -> bool;
|
||||
/// A shared lock for read/renew/write operations; memory stores need only the process lock.
|
||||
fn lock_file(&self) -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -658,6 +717,9 @@ impl SecretStore for SystemStore {
|
||||
fn persistent(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn lock_file(&self) -> Option<PathBuf> {
|
||||
Some(crate::backend::data_dir().join("account-session.lock"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Keychain on macOS, Credential Manager on Windows (the keyring crate). Credential Manager takes at
|
||||
@@ -916,6 +978,9 @@ impl SecretStore for SystemStore {
|
||||
fn persistent(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn lock_file(&self) -> Option<PathBuf> {
|
||||
Some(crate::backend::data_dir().join("account-session.lock"))
|
||||
}
|
||||
}
|
||||
|
||||
pub const NO_KEYCHAIN: &str = "No keychain available: you are signed in until the launcher closes, then you will have to sign in again.";
|
||||
@@ -1016,17 +1081,22 @@ pub fn valid_offline_name(name: &str) -> bool {
|
||||
}
|
||||
|
||||
impl Accounts {
|
||||
fn path() -> PathBuf {
|
||||
pub(crate) fn path() -> PathBuf {
|
||||
crate::backend::data_dir().join("accounts.json")
|
||||
}
|
||||
pub fn load() -> Self {
|
||||
std::fs::read_to_string(Self::path())
|
||||
Self::load_from(&Self::path())
|
||||
}
|
||||
pub fn save(&self) -> Result<(), String> {
|
||||
self.save_to(&Self::path())
|
||||
}
|
||||
pub(crate) fn load_from(path: &Path) -> Self {
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|t| serde_json::from_str(&t).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
pub fn save(&self) -> Result<(), String> {
|
||||
let path = Self::path();
|
||||
pub(crate) fn save_to(&self, path: &Path) -> Result<(), String> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
@@ -1050,6 +1120,16 @@ impl Accounts {
|
||||
store: &dyn SecretStore,
|
||||
account: Account,
|
||||
secrets: &Secrets,
|
||||
) -> Result<(), String> {
|
||||
let _operation = session::lock(store)?;
|
||||
self.keep_sign_in(store, account, secrets)
|
||||
}
|
||||
|
||||
fn keep_sign_in(
|
||||
&mut self,
|
||||
store: &dyn SecretStore,
|
||||
account: Account,
|
||||
secrets: &Secrets,
|
||||
) -> Result<(), String> {
|
||||
store.set(
|
||||
&account.uuid,
|
||||
@@ -1189,6 +1269,7 @@ impl Accounts {
|
||||
/// Sign out: the secrets leave the keychain, the account the list. Offline names stay, but
|
||||
/// play only once an account that owns the game is back.
|
||||
pub fn remove(&mut self, store: &dyn SecretStore, uuid: &str) -> Result<(), String> {
|
||||
let _operation = session::lock(store)?;
|
||||
store.delete(uuid)?;
|
||||
self.accounts.retain(|a| a.uuid != uuid);
|
||||
if self.selected.as_deref() == Some(uuid) {
|
||||
@@ -1252,6 +1333,7 @@ pub fn launch(
|
||||
break;
|
||||
}
|
||||
let plays = Some(&account.uuid) == selected.as_ref();
|
||||
let operation = session::lock(store);
|
||||
let Some(secrets) = secrets_in(store, &account.uuid) else {
|
||||
if plays {
|
||||
pass.offline = Some(format!(
|
||||
@@ -1262,7 +1344,11 @@ pub fn launch(
|
||||
continue;
|
||||
};
|
||||
let before = serde_json::to_string(&secrets).ok();
|
||||
let (said, secrets) = match check(ep, client_id, &account, secrets) {
|
||||
let checked = match &operation {
|
||||
Ok(_) => check(ep, client_id, &account, secrets),
|
||||
Err(e) => Check::NoAnswer(e.clone(), secrets),
|
||||
};
|
||||
let (said, secrets) = match checked {
|
||||
Check::Owns(renewed, secrets) => {
|
||||
accounts.update(renewed.clone());
|
||||
if plays {
|
||||
@@ -1362,12 +1448,47 @@ pub fn write_session_file(
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Called under the account-operation lock, after the final profile is known. Rejected or
|
||||
/// cancelled sign-ins must not save a credential, add another identity, or change selection.
|
||||
fn accept_sign_in(
|
||||
accounts: &mut Accounts,
|
||||
store: &dyn SecretStore,
|
||||
expected: Option<&Account>,
|
||||
account: Account,
|
||||
secrets: &Secrets,
|
||||
cancelled: &dyn Fn() -> bool,
|
||||
progress: &dyn Fn(session::Phase),
|
||||
) -> Result<(), String> {
|
||||
if cancelled() {
|
||||
return Err("Sign-in cancelled.".into());
|
||||
}
|
||||
if let Some(expected) = expected {
|
||||
if expected.uuid != account.uuid {
|
||||
return Err(format!(
|
||||
"Signed in as {} instead of {}. Use the Microsoft account for {} and try again.",
|
||||
account.name, expected.name, expected.name
|
||||
));
|
||||
}
|
||||
if accounts.find(&expected.uuid).is_none() {
|
||||
return Err("That account was signed out while sign-in was in progress. Add it again to continue.".into());
|
||||
}
|
||||
}
|
||||
let selected = accounts.selected.clone();
|
||||
progress(session::Phase::Saving);
|
||||
accounts.keep_sign_in(store, account, secrets)?;
|
||||
if expected.is_some() {
|
||||
accounts.selected = selected;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where a background sign-in is (the window and the MCP server show it).
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum SignInState {
|
||||
Starting,
|
||||
/// Waiting for the player at `verification_uri` with `user_code`.
|
||||
Code(DeviceCode),
|
||||
Working(session::Phase),
|
||||
Done(Result<Account, String>),
|
||||
}
|
||||
|
||||
@@ -1379,6 +1500,15 @@ pub struct SignIn {
|
||||
|
||||
impl SignIn {
|
||||
pub fn start() -> Self {
|
||||
Self::start_expected(None)
|
||||
}
|
||||
|
||||
/// Restore this exact Minecraft identity without changing who currently plays.
|
||||
pub fn start_for(account: Account) -> Self {
|
||||
Self::start_expected(Some(account))
|
||||
}
|
||||
|
||||
fn start_expected(expected: Option<Account>) -> Self {
|
||||
use std::sync::atomic::Ordering;
|
||||
let state = std::sync::Arc::new(Mutex::new(SignInState::Starting));
|
||||
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
@@ -1393,9 +1523,22 @@ impl SignIn {
|
||||
let ms = wait_for_sign_in(&ep, &client_id, &code, &|| {
|
||||
thread_cancel.load(Ordering::Relaxed)
|
||||
})?;
|
||||
let (account, secrets) = finish_sign_in(&ep, ms)?;
|
||||
let progress = |phase| set(SignInState::Working(phase));
|
||||
let (account, secrets) = finish_sign_in_with_progress(&ep, ms, &progress)?;
|
||||
progress(session::Phase::Queued);
|
||||
let store = store();
|
||||
let _operation = session::lock(store)?;
|
||||
let mut accounts = Accounts::load();
|
||||
accounts.add(store(), account.clone(), &secrets)?;
|
||||
accept_sign_in(
|
||||
&mut accounts,
|
||||
store,
|
||||
expected.as_ref(),
|
||||
account.clone(),
|
||||
&secrets,
|
||||
&|| thread_cancel.load(Ordering::Relaxed),
|
||||
&progress,
|
||||
)?;
|
||||
progress(session::Phase::Saving);
|
||||
accounts.save()?;
|
||||
Ok(account)
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
//! Launcher-owned authentication operations. UI, CLI and a future game bridge receive only
|
||||
//! public status; credentials stay behind SecretStore and the existing session-file handoff.
|
||||
use super::*;
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::sync::MutexGuard;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Public progress only; no account credentials or provider response bodies.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Phase {
|
||||
Queued,
|
||||
Keychain,
|
||||
Microsoft,
|
||||
Xbox,
|
||||
Minecraft,
|
||||
Ownership,
|
||||
Profile,
|
||||
Saving,
|
||||
SigningOut,
|
||||
}
|
||||
|
||||
impl Phase {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Queued => "Waiting for account access…",
|
||||
Self::Keychain => "Reading keychain…",
|
||||
Self::Microsoft => "Renewing Microsoft sign-in…",
|
||||
Self::Xbox => "Signing in to Xbox Live…",
|
||||
Self::Minecraft => "Getting Minecraft session…",
|
||||
Self::Ownership => "Checking Minecraft ownership…",
|
||||
Self::Profile => "Loading Minecraft profile…",
|
||||
Self::Saving => "Saving sign-in…",
|
||||
Self::SigningOut => "Signing out…",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum State {
|
||||
Ready,
|
||||
NeedsRefresh,
|
||||
SignInRequired,
|
||||
Retry,
|
||||
OwnershipRequired,
|
||||
}
|
||||
|
||||
/// Safe to show or serialize: contains no credential or secret-store contents.
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
pub struct Status {
|
||||
pub state: State,
|
||||
pub expires_at: Option<u64>,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
const DAMAGED_SIGN_IN: &str = "Could not read the saved sign-in. Sign in again.";
|
||||
|
||||
impl Status {
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self.state {
|
||||
State::Ready => "Ready for online play",
|
||||
State::NeedsRefresh => "Needs refresh",
|
||||
State::SignInRequired => "Sign in again",
|
||||
State::Retry => "Could not check sign-in",
|
||||
State::OwnershipRequired => "Minecraft account needs attention",
|
||||
}
|
||||
}
|
||||
|
||||
fn failed(message: &str) -> Self {
|
||||
Self {
|
||||
state: if message == DAMAGED_SIGN_IN {
|
||||
State::SignInRequired
|
||||
} else {
|
||||
State::Retry
|
||||
},
|
||||
expires_at: None,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn from_secrets(secrets: Option<&Secrets>) -> Self {
|
||||
let state = match secrets {
|
||||
None => State::SignInRequired,
|
||||
Some(s) if s.sign_in_required => State::SignInRequired,
|
||||
Some(s) if !s.owns => State::OwnershipRequired,
|
||||
Some(s) if s.minecraft_expires_at <= now() + RENEW_BEFORE => State::NeedsRefresh,
|
||||
Some(_) => State::Ready,
|
||||
};
|
||||
let mut status = Self {
|
||||
state,
|
||||
expires_at: secrets.map(|s| s.minecraft_expires_at),
|
||||
message: String::new(),
|
||||
};
|
||||
status.message = match state {
|
||||
State::SignInRequired => "Sign in again in your browser.".into(),
|
||||
State::OwnershipRequired => {
|
||||
"Check Minecraft: Java Edition ownership and your profile at minecraft.net.".into()
|
||||
}
|
||||
_ => status.label().into(),
|
||||
};
|
||||
status
|
||||
}
|
||||
}
|
||||
|
||||
static OPERATIONS: Mutex<()> = Mutex::new(());
|
||||
|
||||
pub(super) struct Operation {
|
||||
_process: MutexGuard<'static, ()>,
|
||||
_file: Option<File>,
|
||||
}
|
||||
|
||||
/// How long a status read waits behind another account operation (a refresh or a launch check
|
||||
/// can hold the lock for a network timeout) before it answers "busy" instead.
|
||||
const STATUS_WAIT: Duration = Duration::from_secs(3);
|
||||
const BUSY: &str = "Another account operation is running. Try again in a moment.";
|
||||
|
||||
/// Serialize refresh with sign-in, sign-out and launch, before reading any credential.
|
||||
/// The file is a lock only: it never contains tokens. Fail closed if locking fails.
|
||||
pub(super) fn lock(store: &dyn SecretStore) -> Result<Operation, String> {
|
||||
lock_within(store, None)
|
||||
}
|
||||
|
||||
/// [`lock`], giving up with [`BUSY`] after `within` when one is given.
|
||||
pub(super) fn lock_within(
|
||||
store: &dyn SecretStore,
|
||||
within: Option<Duration>,
|
||||
) -> Result<Operation, String> {
|
||||
let deadline = within.map(|d| Instant::now() + d);
|
||||
// Waits for `take` to stop answering None (busy), or until the deadline.
|
||||
let wait = |take: &mut dyn FnMut() -> Result<Option<()>, String>| loop {
|
||||
match take()? {
|
||||
Some(()) => return Ok(()),
|
||||
None if deadline.is_some_and(|d| Instant::now() >= d) => return Err(BUSY.to_string()),
|
||||
None => std::thread::sleep(Duration::from_millis(50)),
|
||||
}
|
||||
};
|
||||
let failed = "Account operation failed. Restart the launcher.";
|
||||
let mut process = None;
|
||||
match deadline {
|
||||
None => process = Some(OPERATIONS.lock().map_err(|_| failed)?),
|
||||
Some(_) => wait(&mut || match OPERATIONS.try_lock() {
|
||||
Ok(guard) => {
|
||||
process = Some(guard);
|
||||
Ok(Some(()))
|
||||
}
|
||||
Err(std::sync::TryLockError::WouldBlock) => Ok(None),
|
||||
Err(std::sync::TryLockError::Poisoned(_)) => Err(failed.into()),
|
||||
})?,
|
||||
}
|
||||
let file = store
|
||||
.lock_file()
|
||||
.map(|path| {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|_| "Could not create the account lock.")?;
|
||||
}
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(path)
|
||||
.map_err(|_| "Could not open the account lock.")?;
|
||||
let failed = "Could not lock account operations.";
|
||||
match deadline {
|
||||
None => file.lock().map_err(|_| failed)?,
|
||||
Some(_) => wait(&mut || match file.try_lock() {
|
||||
Ok(()) => Ok(Some(())),
|
||||
Err(std::fs::TryLockError::WouldBlock) => Ok(None),
|
||||
Err(std::fs::TryLockError::Error(_)) => Err(failed.into()),
|
||||
})?,
|
||||
}
|
||||
Ok::<_, String>(file)
|
||||
})
|
||||
.transpose()?;
|
||||
Ok(Operation {
|
||||
_process: process.expect("taken above"),
|
||||
_file: file,
|
||||
})
|
||||
}
|
||||
|
||||
fn read(store: &dyn SecretStore, uuid: &str) -> Result<Option<Secrets>, String> {
|
||||
store
|
||||
.get(uuid)
|
||||
.map_err(|_| "Could not read the keychain. Unlock it and retry.".to_string())?
|
||||
.map(|text| serde_json::from_str(&text).map_err(|_| DAMAGED_SIGN_IN.into()))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// Inspect local expiry and remembered failures without contacting authentication services.
|
||||
/// Call off the UI thread: unlocking the keychain can require user interaction.
|
||||
/// Waits at most `STATUS_WAIT` behind another account operation (`account list`, MCP).
|
||||
pub fn status(store: &dyn SecretStore, uuid: &str) -> Status {
|
||||
status_within(store, uuid, &|_| {}, Some(STATUS_WAIT))
|
||||
}
|
||||
|
||||
/// [`status`] for the launcher's own status workers: they run off the UI thread and show
|
||||
/// "Waiting for account access…" meanwhile, so they wait for the lock instead of answering
|
||||
/// [`BUSY`], which would show a false "Could not check sign-in" behind another account's renewal.
|
||||
pub fn status_with_progress(
|
||||
store: &dyn SecretStore,
|
||||
uuid: &str,
|
||||
progress: &dyn Fn(Phase),
|
||||
) -> Status {
|
||||
status_within(store, uuid, progress, None)
|
||||
}
|
||||
|
||||
fn status_within(
|
||||
store: &dyn SecretStore,
|
||||
uuid: &str,
|
||||
progress: &dyn Fn(Phase),
|
||||
within: Option<Duration>,
|
||||
) -> Status {
|
||||
let result = (|| {
|
||||
progress(Phase::Queued);
|
||||
let _operation = lock_within(store, within)?;
|
||||
progress(Phase::Keychain);
|
||||
Ok::<_, String>(Status::from_secrets(read(store, uuid)?.as_ref()))
|
||||
})();
|
||||
result.unwrap_or_else(|e| Status::failed(&e))
|
||||
}
|
||||
|
||||
/// Force a renewal even when the access token is fresh. Does not select an account, change
|
||||
/// the public account list, or return a token. A future IPC handler can call this same API,
|
||||
/// then use write_session_file for an explicit game-session handoff.
|
||||
pub fn refresh(who: &str) -> Result<Status, String> {
|
||||
refresh_with_progress(who, &|_| {})
|
||||
}
|
||||
|
||||
pub fn refresh_with_progress(who: &str, progress: &dyn Fn(Phase)) -> Result<Status, String> {
|
||||
let account = Accounts::load()
|
||||
.find(who)
|
||||
.cloned()
|
||||
.ok_or("That account isn't signed in.")?;
|
||||
Ok(refresh_account(
|
||||
&Endpoints::live(),
|
||||
client_id().as_deref(),
|
||||
store(),
|
||||
&account,
|
||||
&Accounts::path(),
|
||||
progress,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn refresh_with(
|
||||
ep: &Endpoints,
|
||||
client_id: Option<&str>,
|
||||
store: &dyn SecretStore,
|
||||
account: &Account,
|
||||
accounts: &Path,
|
||||
) -> Status {
|
||||
refresh_account(ep, client_id, store, account, accounts, &|_| {})
|
||||
}
|
||||
|
||||
/// `accounts`: the accounts.json a renewed name or skin is saved to (`Accounts::path()`).
|
||||
pub(super) fn refresh_account(
|
||||
ep: &Endpoints,
|
||||
client_id: Option<&str>,
|
||||
store: &dyn SecretStore,
|
||||
account: &Account,
|
||||
accounts: &Path,
|
||||
progress: &dyn Fn(Phase),
|
||||
) -> Status {
|
||||
let result = (|| {
|
||||
progress(Phase::Queued);
|
||||
let _operation = lock(store)?;
|
||||
progress(Phase::Keychain);
|
||||
let Some(secrets) = read(store, &account.uuid)? else {
|
||||
return Ok(Status::from_secrets(None));
|
||||
};
|
||||
let before =
|
||||
serde_json::to_string(&secrets).map_err(|_| "Could not read the saved sign-in.")?;
|
||||
let (mut status, secrets) =
|
||||
match check_with(ep, client_id, account, secrets, true, progress) {
|
||||
Check::Owns(renewed, s) => {
|
||||
// A new name or skin goes to accounts.json, as launch() saves it, so the
|
||||
// row and its profile head follow the refresh, not the next Play.
|
||||
let mut saved = Accounts::load_from(accounts);
|
||||
if saved
|
||||
.accounts
|
||||
.iter()
|
||||
.any(|a| a.uuid == renewed.uuid && *a != renewed)
|
||||
{
|
||||
saved.update(renewed);
|
||||
let _ = saved.save_to(accounts);
|
||||
}
|
||||
(Status::from_secrets(Some(&s)), s)
|
||||
}
|
||||
Check::DoesNot(s) => (Status::from_secrets(Some(&s)), s),
|
||||
Check::NoAnswer(e, s) => {
|
||||
let mut status = Status::from_secrets(Some(&s));
|
||||
if !s.sign_in_required {
|
||||
status.state = State::Retry;
|
||||
}
|
||||
status.message = e;
|
||||
(status, s)
|
||||
}
|
||||
};
|
||||
let after =
|
||||
serde_json::to_string(&secrets).map_err(|_| "Could not save the renewed sign-in.")?;
|
||||
if before != after {
|
||||
progress(Phase::Saving);
|
||||
}
|
||||
if before != after && store.set(&account.uuid, &after).is_err() {
|
||||
status.state = State::Retry;
|
||||
status.message =
|
||||
"The renewed sign-in could not be saved in the keychain. Unlock it and retry."
|
||||
.into();
|
||||
}
|
||||
Ok::<_, String>(status)
|
||||
})();
|
||||
result.unwrap_or_else(|e| Status::failed(&e))
|
||||
}
|
||||
|
||||
/// Renew `who` for a game already running (a server refused its session, or a splitscreen
|
||||
/// player joins one) and hand it over as at launch: a fresh read-once file in `dir`. No file
|
||||
/// unless the renewal left the account ready; the status then says why (sign in again, retry).
|
||||
pub fn renew_for_game(dir: &Path, who: &str) -> Result<PathBuf, Status> {
|
||||
let account = Accounts::load()
|
||||
.find(who)
|
||||
.cloned()
|
||||
.ok_or_else(|| Status::from_secrets(None))?;
|
||||
renew_for_game_with(
|
||||
&Endpoints::live(),
|
||||
client_id().as_deref(),
|
||||
store(),
|
||||
&account,
|
||||
&Accounts::path(),
|
||||
dir,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn renew_for_game_with(
|
||||
ep: &Endpoints,
|
||||
client_id: Option<&str>,
|
||||
store: &dyn SecretStore,
|
||||
account: &Account,
|
||||
accounts: &Path,
|
||||
dir: &Path,
|
||||
) -> Result<PathBuf, Status> {
|
||||
let status = refresh_account(ep, client_id, store, account, accounts, &|_| {});
|
||||
if status.state != State::Ready {
|
||||
return Err(status);
|
||||
}
|
||||
let _operation = lock(store).map_err(|e| Status::failed(&e))?;
|
||||
let secrets = read(store, &account.uuid)
|
||||
.map_err(|e| Status::failed(&e))?
|
||||
.ok_or_else(|| Status::from_secrets(None))?;
|
||||
write_session_file(dir, account, &secrets).map_err(|e| Status::failed(&e))
|
||||
}
|
||||
@@ -309,6 +309,7 @@ fn notch() -> Account {
|
||||
fn secrets(token: &str, owns: bool) -> Secrets {
|
||||
Secrets {
|
||||
refresh_token: "ms-refresh-1".into(),
|
||||
sign_in_required: false,
|
||||
minecraft_token: token.into(),
|
||||
minecraft_expires_at: now() + 7200,
|
||||
xuid: None,
|
||||
@@ -374,6 +375,17 @@ fn signed_in(store: &MemoryStore, secrets: &Secrets) -> Accounts {
|
||||
accounts
|
||||
}
|
||||
|
||||
/// An accounts.json that is never there: a refresh in these tests saves no profile, and never
|
||||
/// touches the developer's own launcher data.
|
||||
fn nowhere() -> PathBuf {
|
||||
std::env::temp_dir()
|
||||
.join(format!(
|
||||
"horizon-launcher-no-accounts-{}",
|
||||
std::process::id()
|
||||
))
|
||||
.join("accounts.json")
|
||||
}
|
||||
|
||||
fn kept(store: &MemoryStore) -> Secrets {
|
||||
secrets_in(store, UUID).unwrap()
|
||||
}
|
||||
@@ -634,6 +646,7 @@ fn accounts_keep_secrets_in_the_store_only() {
|
||||
};
|
||||
let secrets = Secrets {
|
||||
refresh_token: "r".into(),
|
||||
sign_in_required: false,
|
||||
minecraft_token: "mc-secret".into(),
|
||||
minecraft_expires_at: 1,
|
||||
xuid: None,
|
||||
@@ -896,6 +909,7 @@ fn session_files_are_private_and_complete() {
|
||||
};
|
||||
let secrets = Secrets {
|
||||
refresh_token: "r".into(),
|
||||
sign_in_required: false,
|
||||
minecraft_token: "mc-token".into(),
|
||||
minecraft_expires_at: 1,
|
||||
xuid: Some("25".into()),
|
||||
@@ -1027,6 +1041,388 @@ fn the_java_edition_plays_as_an_owner_even_offline() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_refresh_renews_a_fresh_token_without_selecting_another_account() {
|
||||
let store = MemoryStore::default();
|
||||
let accounts = signed_in(&store, &secrets("original-minecraft", true));
|
||||
let (m, ep) = mock(|_| {});
|
||||
let status = session::refresh_with(&ep, Some("test-client"), &store, ¬ch(), &nowhere());
|
||||
assert_eq!(status.state, session::State::Ready);
|
||||
let saved = kept(&store);
|
||||
assert_eq!(saved.refresh_token, "ms-refresh-2");
|
||||
assert_eq!(saved.minecraft_token, "mc-token");
|
||||
assert_eq!(accounts.selected.as_deref(), Some(UUID));
|
||||
assert_eq!(
|
||||
m.lock()
|
||||
.unwrap()
|
||||
.log
|
||||
.iter()
|
||||
.filter(|(_, path, _)| path == "/ms/token")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
let public = serde_json::to_string(&status).unwrap();
|
||||
for secret in ["ms-refresh-2", "mc-token", "original-minecraft"] {
|
||||
assert!(!public.contains(secret));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refresh_saves_a_renewed_profile_and_leaves_an_unchanged_one() {
|
||||
let store = MemoryStore::default();
|
||||
let accounts = signed_in(&store, &secrets("original-minecraft", true));
|
||||
let (_, ep) = mock(|_| {});
|
||||
let dir = std::env::temp_dir().join(format!("horizon-launcher-refresh-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let file = dir.join("accounts.json");
|
||||
accounts.save_to(&file).unwrap();
|
||||
// The services' profile has a skin and a cape now; the saved one has neither.
|
||||
let status = session::refresh_with(&ep, Some("test-client"), &store, ¬ch(), &file);
|
||||
assert_eq!(status.state, session::State::Ready);
|
||||
let saved = Accounts::load_from(&file);
|
||||
let account = saved.find(UUID).unwrap();
|
||||
assert!(account.skin.is_some() && account.cape.is_some());
|
||||
assert_eq!(saved.selected.as_deref(), Some(UUID));
|
||||
// Already the services' profile: the file is not written again.
|
||||
let text = serde_json::to_string(&saved).unwrap();
|
||||
std::fs::write(&file, &text).unwrap();
|
||||
let renewed = saved.find(UUID).unwrap().clone();
|
||||
session::refresh_with(&ep, Some("test-client"), &store, &renewed, &file);
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), text);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_running_game_gets_a_renewed_session_only_when_ready() {
|
||||
let store = MemoryStore::default();
|
||||
signed_in(&store, &secrets("old-minecraft", true));
|
||||
let (_, ep) = mock(|_| {});
|
||||
let dir = std::env::temp_dir().join(format!("horizon-launcher-renew-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let file =
|
||||
session::renew_for_game_with(&ep, Some("test-client"), &store, ¬ch(), &nowhere(), &dir)
|
||||
.unwrap();
|
||||
let handed: Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
|
||||
assert_eq!(handed["uuid"], UUID);
|
||||
assert_eq!(handed["access_token"], "mc-token");
|
||||
// A refused refresh hands over nothing and says to sign in again.
|
||||
signed_in(
|
||||
&store,
|
||||
&Secrets {
|
||||
refresh_token: "revoked".into(),
|
||||
..secrets("old-minecraft", true)
|
||||
},
|
||||
);
|
||||
let refused =
|
||||
session::renew_for_game_with(&ep, Some("test-client"), &store, ¬ch(), &nowhere(), &dir)
|
||||
.unwrap_err();
|
||||
assert_eq!(refused.state, session::State::SignInRequired);
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revoked_refresh_is_remembered_until_a_successful_sign_in() {
|
||||
let store = MemoryStore::default();
|
||||
let mut accounts = signed_in(
|
||||
&store,
|
||||
&Secrets {
|
||||
refresh_token: "revoked".into(),
|
||||
..secrets("old-minecraft", true)
|
||||
},
|
||||
);
|
||||
let (m, ep) = mock(|_| {});
|
||||
let status = session::refresh_with(&ep, Some("test-client"), &store, ¬ch(), &nowhere());
|
||||
assert_eq!(status.state, session::State::SignInRequired);
|
||||
let requests = m.lock().unwrap().log.len();
|
||||
let _ = session::refresh_with(&ep, Some("test-client"), &store, ¬ch(), &nowhere());
|
||||
let pass = launch(&ep, Some("test-client"), &store, &mut accounts).unwrap();
|
||||
assert!(pass.session.is_none());
|
||||
assert_eq!(pass.offline.as_deref(), Some(SIGN_IN_AGAIN));
|
||||
assert_eq!(m.lock().unwrap().log.len(), requests);
|
||||
assert_eq!(
|
||||
session::status(&store, UUID).state,
|
||||
session::State::SignInRequired
|
||||
);
|
||||
accounts
|
||||
.add(&store, notch(), &secrets("new-minecraft", true))
|
||||
.unwrap();
|
||||
assert_eq!(session::status(&store, UUID).state, session::State::Ready);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_connection_failure_is_retryable_and_keeps_the_saved_sign_in() {
|
||||
let store = MemoryStore::default();
|
||||
let _accounts = signed_in(&store, &secrets("still-usable", true));
|
||||
let before = store.get(UUID).unwrap();
|
||||
let status = session::refresh_with(
|
||||
&offline(),
|
||||
Some("test-client"),
|
||||
&store,
|
||||
¬ch(),
|
||||
&nowhere(),
|
||||
);
|
||||
assert_eq!(status.state, session::State::Retry);
|
||||
assert_eq!(store.get(UUID).unwrap(), before);
|
||||
assert!(!kept(&store).sign_in_required);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rotated_microsoft_credential_survives_a_later_service_failure() {
|
||||
let store = MemoryStore::default();
|
||||
let _accounts = signed_in(&store, &secrets("old-minecraft", true));
|
||||
let (_, ep) = mock(|m| m.xerr = Some(2148916238));
|
||||
let status = session::refresh_with(&ep, Some("test-client"), &store, ¬ch(), &nowhere());
|
||||
assert_eq!(status.state, session::State::Retry);
|
||||
assert_eq!(kept(&store).refresh_token, "ms-refresh-2");
|
||||
assert_eq!(kept(&store).minecraft_token, "old-minecraft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expiry_is_refreshable_and_a_missing_credential_needs_sign_in() {
|
||||
let store = MemoryStore::default();
|
||||
assert_eq!(
|
||||
session::status(&store, UUID).state,
|
||||
session::State::SignInRequired
|
||||
);
|
||||
let mut accounts = signed_in(
|
||||
&store,
|
||||
&Secrets {
|
||||
minecraft_expires_at: 0,
|
||||
..secrets("expired", true)
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
session::status(&store, UUID).state,
|
||||
session::State::NeedsRefresh
|
||||
);
|
||||
let (_, ep) = mock(|_| {});
|
||||
assert!(launch(&ep, Some("test-client"), &store, &mut accounts)
|
||||
.unwrap()
|
||||
.session
|
||||
.is_some());
|
||||
assert_eq!(session::status(&store, UUID).state, session::State::Ready);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_serializes_with_sign_out_and_cannot_restore_a_deleted_credential() {
|
||||
let store = Arc::new(MemoryStore::default());
|
||||
let mut accounts = signed_in(&store, &secrets("old-minecraft", true));
|
||||
let (_, ep) = mock(|_| {});
|
||||
let refresh_store = store.clone();
|
||||
let refresh = std::thread::spawn(move || {
|
||||
session::refresh_with(
|
||||
&ep,
|
||||
Some("test-client"),
|
||||
&*refresh_store,
|
||||
¬ch(),
|
||||
&nowhere(),
|
||||
)
|
||||
});
|
||||
accounts.remove(&*store, UUID).unwrap();
|
||||
let _ = refresh.join().unwrap();
|
||||
assert!(store.get(UUID).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_saved_sign_in_is_not_overwritten_or_exposed() {
|
||||
let store = MemoryStore::default();
|
||||
store.set(UUID, "damaged-secret-contents").unwrap();
|
||||
let status = session::refresh_with(
|
||||
&offline(),
|
||||
Some("test-client"),
|
||||
&store,
|
||||
¬ch(),
|
||||
&nowhere(),
|
||||
);
|
||||
assert_eq!(status.state, session::State::SignInRequired);
|
||||
assert_eq!(
|
||||
store.get(UUID).unwrap().as_deref(),
|
||||
Some("damaged-secret-contents")
|
||||
);
|
||||
assert!(!serde_json::to_string(&status)
|
||||
.unwrap()
|
||||
.contains("damaged-secret-contents"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_keychain_is_retryable_without_any_write() {
|
||||
struct Locked;
|
||||
impl SecretStore for Locked {
|
||||
fn get(&self, _: &str) -> Result<Option<String>, String> {
|
||||
Err("locked-keychain-details".into())
|
||||
}
|
||||
fn set(&self, _: &str, _: &str) -> Result<(), String> {
|
||||
panic!("must not overwrite an unreadable credential")
|
||||
}
|
||||
fn delete(&self, _: &str) -> Result<(), String> {
|
||||
panic!("must not delete an unreadable credential")
|
||||
}
|
||||
fn persistent(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
let status = session::refresh_with(
|
||||
&offline(),
|
||||
Some("test-client"),
|
||||
&Locked,
|
||||
¬ch(),
|
||||
&nowhere(),
|
||||
);
|
||||
assert_eq!(status.state, session::State::Retry);
|
||||
assert!(!status.message.contains("locked-keychain-details"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn targeted_sign_in_rejects_another_identity_without_writing_or_selecting_it() {
|
||||
let store = MemoryStore::default();
|
||||
let mut accounts = signed_in(&store, &secrets("original", true));
|
||||
let before = store.get(UUID).unwrap();
|
||||
let mut wrong = notch();
|
||||
wrong.uuid = "11111111111111111111111111111111".into();
|
||||
wrong.name = "OtherPlayer".into();
|
||||
let err = accept_sign_in(
|
||||
&mut accounts,
|
||||
&store,
|
||||
Some(¬ch()),
|
||||
wrong.clone(),
|
||||
&secrets("wrong", true),
|
||||
&|| false,
|
||||
&|_| {},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.contains("OtherPlayer") && err.contains("Notch"));
|
||||
assert_eq!(store.get(UUID).unwrap(), before);
|
||||
assert!(store.get(&wrong.uuid).unwrap().is_none());
|
||||
assert_eq!(accounts.accounts, vec![notch()]);
|
||||
assert_eq!(accounts.selected.as_deref(), Some(UUID));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn targeted_sign_in_preserves_the_selected_account_or_offline_name() {
|
||||
let store = MemoryStore::default();
|
||||
let mut accounts = signed_in(&store, &secrets("old", true));
|
||||
let mut other = notch();
|
||||
other.uuid = "11111111111111111111111111111111".into();
|
||||
accounts
|
||||
.add(&store, other.clone(), &secrets("other", true))
|
||||
.unwrap();
|
||||
accept_sign_in(
|
||||
&mut accounts,
|
||||
&store,
|
||||
Some(¬ch()),
|
||||
notch(),
|
||||
&secrets("renewed", true),
|
||||
&|| false,
|
||||
&|_| {},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(accounts.selected.as_deref(), Some(other.uuid.as_str()));
|
||||
assert_eq!(kept(&store).minecraft_token, "renewed");
|
||||
accounts.selected = None;
|
||||
accounts.offline_selected = Some("Builder".into());
|
||||
accept_sign_in(
|
||||
&mut accounts,
|
||||
&store,
|
||||
Some(¬ch()),
|
||||
notch(),
|
||||
&secrets("renewed-again", true),
|
||||
&|| false,
|
||||
&|_| {},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(accounts.selected.is_none());
|
||||
assert_eq!(accounts.offline_selected.as_deref(), Some("Builder"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_or_removed_targeted_sign_in_keeps_credentials_unchanged() {
|
||||
let store = MemoryStore::default();
|
||||
let mut accounts = signed_in(&store, &secrets("original", true));
|
||||
let before = store.get(UUID).unwrap();
|
||||
assert!(accept_sign_in(
|
||||
&mut accounts,
|
||||
&store,
|
||||
Some(¬ch()),
|
||||
notch(),
|
||||
&secrets("new", true),
|
||||
&|| true,
|
||||
&|_| {}
|
||||
)
|
||||
.is_err());
|
||||
assert_eq!(store.get(UUID).unwrap(), before);
|
||||
accounts.accounts.clear();
|
||||
assert!(accept_sign_in(
|
||||
&mut accounts,
|
||||
&store,
|
||||
Some(¬ch()),
|
||||
notch(),
|
||||
&secrets("new", true),
|
||||
&|| false,
|
||||
&|_| {}
|
||||
)
|
||||
.is_err());
|
||||
assert_eq!(store.get(UUID).unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_reports_real_service_steps_in_order() {
|
||||
use session::Phase::*;
|
||||
let store = MemoryStore::default();
|
||||
let _accounts = signed_in(&store, &secrets("old", true));
|
||||
let (_, ep) = mock(|_| {});
|
||||
let phases = std::cell::RefCell::new(Vec::new());
|
||||
let status = session::refresh_account(
|
||||
&ep,
|
||||
Some("test-client"),
|
||||
&store,
|
||||
¬ch(),
|
||||
&nowhere(),
|
||||
&|phase| phases.borrow_mut().push(phase),
|
||||
);
|
||||
assert_eq!(status.state, session::State::Ready);
|
||||
assert_eq!(
|
||||
*phases.borrow(),
|
||||
[Queued, Keychain, Microsoft, Xbox, Minecraft, Ownership, Profile, Saving]
|
||||
);
|
||||
let mut accounts = signed_in(&store, &secrets("old", true));
|
||||
let (account, secrets) = finish_sign_in_with_progress(
|
||||
&ep,
|
||||
MsTokens {
|
||||
access: "ms-access-1".into(),
|
||||
refresh: "fresh".into(),
|
||||
},
|
||||
&|phase| phases.borrow_mut().push(phase),
|
||||
)
|
||||
.unwrap();
|
||||
accept_sign_in(
|
||||
&mut accounts,
|
||||
&store,
|
||||
Some(¬ch()),
|
||||
account,
|
||||
&secrets,
|
||||
&|| false,
|
||||
&|phase| phases.borrow_mut().push(phase),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
&phases.borrow()[8..],
|
||||
&[Xbox, Minecraft, Ownership, Profile, Saving]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_status_read_gives_up_behind_a_long_operation() {
|
||||
let store = MemoryStore::default();
|
||||
let held = session::lock(&store).unwrap();
|
||||
let start = std::time::Instant::now();
|
||||
let busy = session::lock_within(&store, Some(std::time::Duration::from_millis(150)));
|
||||
assert!(busy.is_err(), "a held lock must not be taken twice");
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(2));
|
||||
drop(held);
|
||||
assert!(session::lock_within(&store, Some(std::time::Duration::from_secs(5))).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_launch_as_another_account_leaves_the_selected_one() {
|
||||
let store = MemoryStore::default();
|
||||
|
||||
@@ -2199,6 +2199,10 @@ pub fn client_command(tag: &str) -> Command {
|
||||
let _ = fs::create_dir_all(&home);
|
||||
let mut cmd = Command::new(client_program(tag));
|
||||
cmd.current_dir(home).arg("--resources").arg(resources(tag));
|
||||
// How the game asks for a renewed session (`account session`) while it runs.
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
cmd.env("HORIZON_LAUNCHER_EXE", exe);
|
||||
}
|
||||
// PipeWire remembers volume and mute per application name. The game's own name is
|
||||
// "PipeWire ALSA [horizon-client]", shared with development and test runs, where
|
||||
// muting it once left every later game silent: played from here it is "Horizon".
|
||||
@@ -3106,6 +3110,11 @@ pub enum Msg {
|
||||
Keychain(Option<String>),
|
||||
/// Whether an account in the launcher owned the game at its last check (the owner gate).
|
||||
Owner(bool),
|
||||
/// Credential-free result from a background account operation.
|
||||
AccountStatus(String, u64, crate::account::session::Status),
|
||||
AccountProgress(String, u64, crate::account::session::Phase),
|
||||
AccountHead(String, Option<String>, Option<Vec<[u8; 4]>>, bool),
|
||||
AccountRemoved(String, u64, Result<bool, String>),
|
||||
/// The owner gate's answer for the game about to start: the selected account's session
|
||||
/// file (none = offline) and why it plays offline, if it does.
|
||||
Session(Result<(Option<PathBuf>, Option<String>), crate::account::NoOwner>),
|
||||
|
||||
+65
-3
@@ -108,7 +108,17 @@ enum AccountCmd {
|
||||
List,
|
||||
/// Sign in with a Microsoft account in your browser (device code). Play needs one that owns
|
||||
/// Minecraft: Java Edition
|
||||
SignIn,
|
||||
SignIn {
|
||||
/// Sign this account (name or UUID) in again: another Microsoft account is refused and
|
||||
/// who plays stays the same
|
||||
#[arg(long = "for")]
|
||||
target: Option<String>,
|
||||
},
|
||||
/// Renew an account's session now, even when its access token is still fresh
|
||||
Refresh { who: String },
|
||||
/// Renew an account's session for a game already running: prints the read-once session file
|
||||
/// it hands over (`--json`: {"session_file"} or {"authentication"} when it can't)
|
||||
Session { who: String },
|
||||
/// Sign out: the account's tokens leave the keychain
|
||||
SignOut {
|
||||
/// Its name or UUID
|
||||
@@ -1754,6 +1764,7 @@ fn account_command(action: AccountCmd, as_json: bool) -> Result<(), String> {
|
||||
"accounts": accounts.accounts.iter().map(|a| serde_json::json!({
|
||||
"name": a.name, "uuid": a.uuid,
|
||||
"selected": accounts.selected.as_ref() == Some(&a.uuid),
|
||||
"authentication": account::session::status(account::store(), &a.uuid),
|
||||
})).collect::<Vec<_>>(),
|
||||
"offline": accounts.offline.iter().map(|n| serde_json::json!({
|
||||
"name": n,
|
||||
@@ -1774,7 +1785,8 @@ fn account_command(action: AccountCmd, as_json: bool) -> Result<(), String> {
|
||||
} else {
|
||||
" "
|
||||
};
|
||||
println!("{mark} {} {}", a.name, a.uuid);
|
||||
let status = account::session::status(account::store(), &a.uuid);
|
||||
println!("{mark} {} {} {}", a.name, a.uuid, status.label());
|
||||
}
|
||||
for n in &accounts.offline {
|
||||
let on = accounts.player() == account::Player::Offline(n.clone());
|
||||
@@ -1790,7 +1802,57 @@ fn account_command(action: AccountCmd, as_json: bool) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
}
|
||||
AccountCmd::SignIn => {
|
||||
AccountCmd::Refresh { who } => {
|
||||
let status = account::session::refresh(&who)?;
|
||||
if as_json {
|
||||
json(serde_json::to_value(&status).map_err(|e| e.to_string())?);
|
||||
} else if status.state != account::session::State::Ready {
|
||||
return Err(format!("{}: {}", status.label(), status.message));
|
||||
} else {
|
||||
println!("{}: {}", status.label(), status.message);
|
||||
}
|
||||
}
|
||||
AccountCmd::Session { who } => {
|
||||
let dir = backend::game_home().join("sessions");
|
||||
match (account::session::renew_for_game(&dir, &who), as_json) {
|
||||
(Ok(file), true) => json(serde_json::json!({ "session_file": file })),
|
||||
(Ok(file), false) => println!("{}", file.display()),
|
||||
(Err(status), true) => json(serde_json::json!({ "authentication": status })),
|
||||
(Err(status), false) => return Err(status.message),
|
||||
}
|
||||
}
|
||||
AccountCmd::SignIn { target: Some(who) } => {
|
||||
let a = find(&accounts, &who)?;
|
||||
let sign_in = account::SignIn::start_for(a.clone());
|
||||
let mut shown = false;
|
||||
let signed_in = loop {
|
||||
match sign_in.state() {
|
||||
account::SignInState::Code(code) if !shown => {
|
||||
shown = true;
|
||||
if as_json {
|
||||
json(serde_json::json!({
|
||||
"link": code.verification_uri,
|
||||
"code": code.user_code,
|
||||
}));
|
||||
} else {
|
||||
eprintln!(
|
||||
"To sign {} in again, open {} in a browser and enter the code {}",
|
||||
a.name, code.verification_uri, code.user_code
|
||||
);
|
||||
}
|
||||
}
|
||||
account::SignInState::Done(result) => break result?,
|
||||
_ => {}
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
};
|
||||
if as_json {
|
||||
json(serde_json::json!({ "signed_in": signed_in.uuid }));
|
||||
} else {
|
||||
println!("Signed in as {} again.", signed_in.name);
|
||||
}
|
||||
}
|
||||
AccountCmd::SignIn { target: None } => {
|
||||
let client_id = account::client_id().ok_or(account::NOT_CONFIGURED)?;
|
||||
let ep = account::Endpoints::live();
|
||||
let code = account::device_code(&ep, &client_id)?;
|
||||
|
||||
@@ -7,8 +7,8 @@ use clap::{ArgAction, Command};
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
/// CLI commands that are not generated tools: the MCP server itself; No AI, the player's switch
|
||||
/// and never an agent's; trusting native mods' code, the player's decision too; and the ones
|
||||
/// mcp.rs runs in the session.
|
||||
/// and never an agent's; trusting native mods' code, the player's decision too; the ones
|
||||
/// mcp.rs runs in the session; and the game's session handoff, a file holding a token.
|
||||
const NOT_TOOLS: &[&str] = &[
|
||||
"mcp",
|
||||
"completions",
|
||||
@@ -22,6 +22,7 @@ const NOT_TOOLS: &[&str] = &[
|
||||
"server",
|
||||
"account sign-in",
|
||||
"account list",
|
||||
"account session",
|
||||
];
|
||||
|
||||
/// Commands that delete the player's things: their tools need `confirm: true`.
|
||||
@@ -258,6 +259,7 @@ fn session_tool(words: &str) -> &'static str {
|
||||
"account list" => "`account_list`",
|
||||
"mcp" => "none: this command is the MCP server",
|
||||
"agent-config" => "none: it prints how to connect a client to `mcp`",
|
||||
"account session" => "none: the game's own session handoff",
|
||||
_ => "none: the player's switch",
|
||||
}
|
||||
}
|
||||
@@ -396,7 +398,14 @@ mod tests {
|
||||
] {
|
||||
assert!(names.iter().any(|n| n == want), "{want} missing: {names:?}");
|
||||
}
|
||||
for not in ["mcp", "no_ai", "play", "server", "account_sign_in"] {
|
||||
for not in [
|
||||
"mcp",
|
||||
"no_ai",
|
||||
"play",
|
||||
"server",
|
||||
"account_sign_in",
|
||||
"account_session",
|
||||
] {
|
||||
assert!(!names.iter().any(|n| n == not), "{not}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,84 @@ pub fn default_face(resources: &Path, uuid: &str) -> Option<Vec<[u8; 4]>> {
|
||||
face(&std::fs::read(path).ok()?)
|
||||
}
|
||||
|
||||
fn profile_pixels(png: &[u8]) -> Option<Vec<[u8; 4]>> {
|
||||
// Check dimensions before decoding a downloaded image; skin heads need no large allocation.
|
||||
let dimensions =
|
||||
image::ImageReader::with_format(std::io::Cursor::new(png), image::ImageFormat::Png)
|
||||
.into_dimensions()
|
||||
.ok()?;
|
||||
matches!(dimensions, (64, 32 | 64))
|
||||
.then(|| face(png))
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// A signed-in profile's public skin, cached independently of its authentication tokens.
|
||||
/// This may do disk/network I/O and must run on a worker. Missing skins use the UUID default;
|
||||
/// the `bool` says the download failed, so the real skin is worth asking for again later.
|
||||
pub fn profile_face(
|
||||
account: &crate::account::Account,
|
||||
cache: &Path,
|
||||
resources: &Path,
|
||||
) -> (Option<Vec<[u8; 4]>>, bool) {
|
||||
let failed = std::cell::Cell::new(false);
|
||||
let face = profile_face_with(account, cache, resources, &|url| {
|
||||
use std::io::Read;
|
||||
failed.set(true);
|
||||
let response = crate::proxy::agent(
|
||||
ureq::AgentBuilder::new().timeout(std::time::Duration::from_secs(10)),
|
||||
url,
|
||||
)
|
||||
.build()
|
||||
.get(url)
|
||||
.call()
|
||||
.ok()?;
|
||||
let mut bytes = Vec::new();
|
||||
response
|
||||
.into_reader()
|
||||
.take(1024 * 1024)
|
||||
.read_to_end(&mut bytes)
|
||||
.ok()?;
|
||||
failed.set(false);
|
||||
Some(bytes)
|
||||
});
|
||||
(face, failed.get())
|
||||
}
|
||||
|
||||
fn profile_face_with(
|
||||
account: &crate::account::Account,
|
||||
cache: &Path,
|
||||
resources: &Path,
|
||||
fetch: &dyn Fn(&str) -> Option<Vec<u8>>,
|
||||
) -> Option<Vec<[u8; 4]>> {
|
||||
use sha1::{Digest, Sha1};
|
||||
if let Some((url, _)) = &account.skin {
|
||||
// Public Minecraft texture URLs only; never send credentials to a skin host.
|
||||
let texture = url
|
||||
.strip_prefix("https://textures.minecraft.net/texture/")
|
||||
.or_else(|| url.strip_prefix("http://textures.minecraft.net/texture/"));
|
||||
if texture
|
||||
.is_some_and(|hash| !hash.is_empty() && hash.bytes().all(|b| b.is_ascii_hexdigit()))
|
||||
{
|
||||
let file = cache.join(format!("{:x}.png", Sha1::digest(url.as_bytes())));
|
||||
if let Some(face) = std::fs::read(&file)
|
||||
.ok()
|
||||
.and_then(|png| profile_pixels(&png))
|
||||
{
|
||||
return Some(face);
|
||||
}
|
||||
if let Some(png) = fetch(url) {
|
||||
if let Some(face) = profile_pixels(&png) {
|
||||
if std::fs::create_dir_all(cache).is_ok() {
|
||||
let _ = std::fs::write(file, png);
|
||||
}
|
||||
return Some(face);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
default_face(resources, &account.uuid)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -95,4 +173,86 @@ mod tests {
|
||||
assert_eq!(face[0], [1, 2, 3, 255]);
|
||||
assert_eq!(face[1], [7, 8, 9, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_heads_are_cached_by_skin_and_work_offline() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"hz-profile-head-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
let mut skin = image::RgbaImage::new(64, 64);
|
||||
skin.put_pixel(8, 8, image::Rgba([12, 34, 56, 255]));
|
||||
let mut png = Vec::new();
|
||||
skin.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let mut account = crate::account::Account {
|
||||
name: "TestPlayer".into(),
|
||||
uuid: "00000000000000000000000000000000".into(),
|
||||
skin: Some((
|
||||
"https://textures.minecraft.net/texture/abc".into(),
|
||||
"classic".into(),
|
||||
)),
|
||||
cape: None,
|
||||
};
|
||||
let calls = std::cell::Cell::new(0);
|
||||
let fetch = |_: &str| {
|
||||
calls.set(calls.get() + 1);
|
||||
Some(png.clone())
|
||||
};
|
||||
let head = profile_face_with(&account, &dir, &dir, &fetch).unwrap();
|
||||
assert_eq!(head[0], [12, 34, 56, 255]);
|
||||
assert_eq!(
|
||||
profile_face_with(&account, &dir, &dir, &|_| panic!(
|
||||
"cached head must not need the network"
|
||||
)),
|
||||
Some(head)
|
||||
);
|
||||
account.skin.as_mut().unwrap().0 = "https://textures.minecraft.net/texture/def".into();
|
||||
assert!(profile_face_with(&account, &dir, &dir, &fetch).is_some());
|
||||
assert_eq!(calls.get(), 2, "skin changes must not reuse an old image");
|
||||
assert!(profile_pixels(&png).is_some());
|
||||
let mut oversized = Vec::new();
|
||||
image::RgbaImage::new(128, 64)
|
||||
.write_to(
|
||||
&mut std::io::Cursor::new(&mut oversized),
|
||||
image::ImageFormat::Png,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(profile_pixels(&oversized).is_none());
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_profile_skin_uses_the_uuid_default() {
|
||||
let dir = std::env::temp_dir().join(format!("hz-profile-default-{}", std::process::id()));
|
||||
let path = dir.join("assets/minecraft/textures/entity/player/slim/alex.png");
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let mut skin = image::RgbaImage::new(64, 64);
|
||||
skin.put_pixel(8, 8, image::Rgba([3, 5, 7, 255]));
|
||||
skin.save(&path).unwrap();
|
||||
let account = crate::account::Account {
|
||||
name: "TestPlayer".into(),
|
||||
uuid: "00000000000000000000000000000000".into(),
|
||||
skin: Some((
|
||||
"https://textures.minecraft.net/texture/abc".into(),
|
||||
"classic".into(),
|
||||
)),
|
||||
cape: None,
|
||||
};
|
||||
let head = profile_face_with(&account, &dir.join("cache"), &dir, &|_| None).unwrap();
|
||||
assert_eq!(head[0], [3, 5, 7, 255]);
|
||||
let mut blocked = account;
|
||||
blocked.skin.as_mut().unwrap().0 = "https://example.com/not-a-minecraft-skin".into();
|
||||
assert!(
|
||||
profile_face_with(&blocked, &dir.join("cache"), &dir, &|_| panic!(
|
||||
"must not fetch an arbitrary profile URL"
|
||||
))
|
||||
.is_some()
|
||||
);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
+382
-41
@@ -40,6 +40,7 @@ mod packs_page;
|
||||
mod partner;
|
||||
mod perf;
|
||||
mod plate;
|
||||
mod profile_heads;
|
||||
mod screenshots_page;
|
||||
mod scrollbar;
|
||||
mod servers_page;
|
||||
@@ -70,7 +71,7 @@ use horizon_launcher::native_status;
|
||||
use horizon_launcher::telemetry;
|
||||
use scrollbar::Scrollbar;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::VecDeque;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::mpsc::{channel, Receiver, Sender};
|
||||
use std::sync::Mutex;
|
||||
@@ -252,6 +253,14 @@ struct Launcher {
|
||||
root_focus: FocusHandle,
|
||||
/// Microsoft accounts (accounts.json), the sign-in under way and what it last showed.
|
||||
accounts: Accounts,
|
||||
auth_status: HashMap<String, account::session::Status>,
|
||||
auth_progress: HashMap<String, account::session::Phase>,
|
||||
profile_heads: profile_heads::Heads,
|
||||
/// When page switches last checked every account's sign-in.
|
||||
auth_checked: Option<Instant>,
|
||||
sign_in_target: Option<account::Account>,
|
||||
auth_busy: HashMap<String, u64>,
|
||||
auth_next: u64,
|
||||
sign_in: Option<SignIn>,
|
||||
sign_in_state: Option<SignInState>,
|
||||
/// What to say when the keychain doesn't keep sign-ins ([`account::no_keychain`]).
|
||||
@@ -500,6 +509,13 @@ impl Launcher {
|
||||
internet: internet::Internet::new(),
|
||||
root_focus: cx.focus_handle(),
|
||||
accounts: Accounts::load(),
|
||||
auth_status: HashMap::new(),
|
||||
auth_progress: HashMap::new(),
|
||||
profile_heads: profile_heads::Heads::default(),
|
||||
auth_checked: None,
|
||||
sign_in_target: None,
|
||||
auth_busy: HashMap::new(),
|
||||
auth_next: 0,
|
||||
sign_in: None,
|
||||
sign_in_state: None,
|
||||
keychain: None,
|
||||
@@ -581,6 +597,8 @@ impl Launcher {
|
||||
let _ = tx.send(Msg::Reused(reused));
|
||||
}
|
||||
});
|
||||
this.load_profile_heads();
|
||||
this.load_auth_statuses(true);
|
||||
this.fetch_startup_releases();
|
||||
this.fetch_links();
|
||||
if home::getting_ready_preview() {
|
||||
@@ -607,6 +625,16 @@ impl Launcher {
|
||||
// changed the accounts: read them again (small files, once a switch).
|
||||
self.offline_name = offline_name();
|
||||
self.accounts = Accounts::load();
|
||||
// Each check reads the keychain (a prompt on macOS): page switches check every account
|
||||
// at most once a minute, and an account added meanwhile at once.
|
||||
if self
|
||||
.auth_checked
|
||||
.is_none_or(|t| t.elapsed() >= Duration::from_secs(60))
|
||||
|| (self.accounts.accounts.iter()).any(|a| !self.auth_status.contains_key(&a.uuid))
|
||||
{
|
||||
self.load_auth_statuses(false);
|
||||
}
|
||||
self.load_profile_heads();
|
||||
if page != self.page {
|
||||
self.page = page;
|
||||
self.switches += 1;
|
||||
@@ -799,6 +827,48 @@ impl Launcher {
|
||||
Msg::Reused(_) => self.installed = backend::installed(),
|
||||
Msg::Keychain(said) => self.keychain = said,
|
||||
Msg::Owner(owns) => self.owner = Some(owns),
|
||||
Msg::AccountHead(uuid, url, face, failed) => {
|
||||
self.profile_heads.loaded(&uuid, &url, face, failed)
|
||||
}
|
||||
Msg::AccountProgress(uuid, generation, phase) => {
|
||||
if self.auth_busy.get(&uuid) == Some(&generation) {
|
||||
self.auth_progress.insert(uuid, phase);
|
||||
}
|
||||
}
|
||||
Msg::AccountStatus(uuid, generation, status) => {
|
||||
if self.auth_busy.get(&uuid) != Some(&generation) {
|
||||
continue;
|
||||
}
|
||||
self.auth_busy.remove(&uuid);
|
||||
self.auth_progress.remove(&uuid);
|
||||
if self.accounts.find(&uuid).is_some() {
|
||||
if status.state == account::session::State::Ready {
|
||||
self.owner = Some(true);
|
||||
}
|
||||
self.auth_status.insert(uuid, status);
|
||||
// A refresh may have saved a new name or skin: show it now.
|
||||
let saved = Accounts::load();
|
||||
if saved != self.accounts {
|
||||
self.accounts = saved;
|
||||
self.load_profile_heads();
|
||||
}
|
||||
}
|
||||
}
|
||||
Msg::AccountRemoved(uuid, generation, result) => {
|
||||
if self.auth_busy.get(&uuid) != Some(&generation) {
|
||||
continue;
|
||||
}
|
||||
self.auth_busy.remove(&uuid);
|
||||
self.auth_progress.remove(&uuid);
|
||||
match result {
|
||||
Ok(owns) => {
|
||||
self.accounts = Accounts::load();
|
||||
self.auth_status.remove(&uuid);
|
||||
self.owner = Some(owns);
|
||||
}
|
||||
Err(e) => self.sign_in_state = Some(SignInState::Done(Err(e))),
|
||||
}
|
||||
}
|
||||
Msg::Playing(playing) => self.playing = playing,
|
||||
Msg::OfficialPlayers(players) => self.official_players = players,
|
||||
Msg::JavaGame(id, home, cmd) => {
|
||||
@@ -825,6 +895,7 @@ impl Launcher {
|
||||
}
|
||||
}
|
||||
Msg::Session(session) => {
|
||||
self.load_auth_statuses(false);
|
||||
if let Some(tag) = self.starting_client.take() {
|
||||
let mut cmd = backend::client_command(&tag);
|
||||
let (file, offline) = match session {
|
||||
@@ -1122,11 +1193,13 @@ impl Launcher {
|
||||
changed |= self.mods_tick();
|
||||
changed |= self.github_tick();
|
||||
if let Some(sign_in) = &self.sign_in {
|
||||
let state = sign_in.state();
|
||||
let changed_state = format!("{state:?}") != format!("{:?}", self.sign_in_state);
|
||||
if changed_state {
|
||||
if let SignInState::Done(Ok(_)) = state {
|
||||
if let Some(state) = sign_in_change(&mut self.sign_in_state, sign_in.state()) {
|
||||
if let SignInState::Done(Ok(ref account)) = state {
|
||||
self.auth_busy.remove(&account.uuid);
|
||||
self.sign_in_target = None;
|
||||
self.accounts = Accounts::load();
|
||||
self.load_profile_heads();
|
||||
self.load_auth_statuses(false);
|
||||
// Sign-in refuses accounts that don't own the game.
|
||||
self.owner = Some(true);
|
||||
self.owner_screen_open = false;
|
||||
@@ -1136,7 +1209,6 @@ impl Launcher {
|
||||
self.settings.save();
|
||||
}
|
||||
}
|
||||
self.sign_in_state = Some(state);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
@@ -1379,12 +1451,110 @@ impl Launcher {
|
||||
});
|
||||
}
|
||||
|
||||
/// Starts the Microsoft sign-in; Settings and the owner gate's screen show its code.
|
||||
fn load_profile_heads(&mut self) {
|
||||
let resources = self
|
||||
.selected()
|
||||
.or_else(|| self.installed.first())
|
||||
.map(|tag| backend::resources(tag))
|
||||
.unwrap_or_default();
|
||||
self.profile_heads
|
||||
.request(&self.accounts.accounts, resources, &self.tx);
|
||||
}
|
||||
|
||||
fn begin_auth_job(&mut self, uuid: &str) -> Option<u64> {
|
||||
if self.auth_busy.contains_key(uuid) {
|
||||
return None;
|
||||
}
|
||||
self.auth_next += 1;
|
||||
self.auth_busy.insert(uuid.into(), self.auth_next);
|
||||
self.auth_progress
|
||||
.insert(uuid.into(), account::session::Phase::Queued);
|
||||
Some(self.auth_next)
|
||||
}
|
||||
|
||||
/// Only workers read the keychain or contact authentication services. Cached public status
|
||||
/// keeps drawing independent of keychain prompts, network timeouts and refresh operations.
|
||||
fn load_auth_statuses(&mut self, renew_due: bool) {
|
||||
self.auth_checked = Some(Instant::now());
|
||||
for account in self.accounts.accounts.clone() {
|
||||
let Some(generation) = self.begin_auth_job(&account.uuid) else {
|
||||
continue;
|
||||
};
|
||||
let tx = self.tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let progress = |phase| {
|
||||
let _ = tx.send(Msg::AccountProgress(
|
||||
account.uuid.clone(),
|
||||
generation,
|
||||
phase,
|
||||
));
|
||||
};
|
||||
progress(account::session::Phase::Keychain);
|
||||
let store = account::store();
|
||||
let mut status =
|
||||
account::session::status_with_progress(store, &account.uuid, &progress);
|
||||
if renew_due && status.state == account::session::State::NeedsRefresh {
|
||||
if let Ok(renewed) =
|
||||
account::session::refresh_with_progress(&account.uuid, &progress)
|
||||
{
|
||||
status = renewed;
|
||||
}
|
||||
}
|
||||
let _ = tx.send(Msg::AccountStatus(account.uuid, generation, status));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_account(&mut self, uuid: String) {
|
||||
let Some(generation) = self.begin_auth_job(&uuid) else {
|
||||
return;
|
||||
};
|
||||
let tx = self.tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let progress = |phase| {
|
||||
let _ = tx.send(Msg::AccountProgress(uuid.clone(), generation, phase));
|
||||
};
|
||||
progress(account::session::Phase::Keychain);
|
||||
if let Ok(status) = account::session::refresh_with_progress(&uuid, &progress) {
|
||||
let _ = tx.send(Msg::AccountStatus(uuid, generation, status));
|
||||
} else {
|
||||
// It may have been removed through the CLI while this window was open.
|
||||
let _ = tx.send(Msg::AccountRemoved(
|
||||
uuid,
|
||||
generation,
|
||||
Ok(Accounts::load().has_owner(account::store())),
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Generic Add account and targeted re-sign-in share the launcher-owned device-code flow.
|
||||
fn start_sign_in(&mut self) {
|
||||
if let Some(old) = self.sign_in.replace(SignIn::start()) {
|
||||
self.begin_sign_in(None);
|
||||
}
|
||||
|
||||
fn re_sign_in(&mut self, uuid: &str) {
|
||||
if let Some(account) = self.accounts.find(uuid).cloned() {
|
||||
self.begin_sign_in(Some(account));
|
||||
}
|
||||
}
|
||||
|
||||
fn begin_sign_in(&mut self, target: Option<account::Account>) {
|
||||
self.cancel_sign_in();
|
||||
self.sign_in = Some(match &target {
|
||||
Some(account) => SignIn::start_for(account.clone()),
|
||||
None => SignIn::start(),
|
||||
});
|
||||
self.sign_in_target = target;
|
||||
self.sign_in_state = Some(SignInState::Starting);
|
||||
}
|
||||
|
||||
fn cancel_sign_in(&mut self) {
|
||||
if let Some(old) = self.sign_in.take() {
|
||||
old.cancel();
|
||||
}
|
||||
self.sign_in_state = Some(SignInState::Starting);
|
||||
self.sign_in_state = None;
|
||||
self.sign_in_target = None;
|
||||
}
|
||||
|
||||
fn choose_folder(&mut self, cx: &mut Context<Self>) {
|
||||
@@ -1809,6 +1979,49 @@ enum PlayPress {
|
||||
Nothing,
|
||||
}
|
||||
|
||||
/// Account state uses the same semantic colors as the launcher's other notices. Keep a
|
||||
/// distinct icon and the full label so color is never the only way to recognize a state.
|
||||
fn account_status_line(
|
||||
status: Option<&account::session::Status>,
|
||||
progress: Option<&str>,
|
||||
t: &Theme,
|
||||
) -> Div {
|
||||
use account::session::State;
|
||||
let (icon, color, label) = if let Some(progress) = progress {
|
||||
("clock", t.muted, progress)
|
||||
} else {
|
||||
let (icon, color) = match status.map(|s| s.state) {
|
||||
Some(State::Ready) => ("check", t.success),
|
||||
Some(State::NeedsRefresh | State::Retry) => ("reload", t.warn),
|
||||
Some(State::SignInRequired) => ("user", t.danger),
|
||||
Some(State::OwnershipRequired) => ("info-box", t.danger),
|
||||
None => ("info-box", t.muted),
|
||||
};
|
||||
(
|
||||
icon,
|
||||
color,
|
||||
status.map_or("Sign-in not checked", |s| s.label()),
|
||||
)
|
||||
};
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap(px(6.))
|
||||
.text_color(color)
|
||||
.child(blocks::icon(icon, 14., color))
|
||||
.child(tr(label).to_owned())
|
||||
}
|
||||
|
||||
/// Polling must handle each sign-in transition once. Replaying a completed sign-in
|
||||
/// restarts every account's status worker and leaves the UI perpetually checking.
|
||||
fn sign_in_change(shown: &mut Option<SignInState>, next: SignInState) -> Option<SignInState> {
|
||||
if shown.as_ref() == Some(&next) {
|
||||
return None;
|
||||
}
|
||||
*shown = Some(next.clone());
|
||||
Some(next)
|
||||
}
|
||||
|
||||
/// `owner`: whether an account here owns the game, by the keychain (`Launcher::owner`).
|
||||
fn play_press(prepared: bool, owner: Option<bool>, busy: bool) -> PlayPress {
|
||||
match (prepared, owner) {
|
||||
@@ -2040,14 +2253,16 @@ impl Launcher {
|
||||
);
|
||||
}
|
||||
// Only a signed-in account says "Microsoft account"; otherwise an offline name, or the
|
||||
// one the game made up. A signed-in one shows its face (its UUID's default skin).
|
||||
// one the game made up. A signed-in one shows its face (its skin, else its UUID's default).
|
||||
let (name, how) = self.who_plays();
|
||||
let face = match self.accounts.player() {
|
||||
account::Player::Microsoft(_) => self.accounts.selected().map(|a| a.uuid.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let picture = match face {
|
||||
Some(uuid) => self.head(&uuid, 28.).into_any_element(),
|
||||
Some(uuid) => (self.profile_heads)
|
||||
.draw(Some(&uuid), 28., self.scale, &t)
|
||||
.into_any_element(),
|
||||
None => div()
|
||||
.flex()
|
||||
.items_center()
|
||||
@@ -2089,6 +2304,7 @@ impl Launcher {
|
||||
if this.account_menu {
|
||||
// Who can play may have changed elsewhere since the window opened.
|
||||
this.accounts = Accounts::load();
|
||||
this.load_profile_heads();
|
||||
}
|
||||
cx.notify();
|
||||
}));
|
||||
@@ -3450,33 +3666,89 @@ impl Launcher {
|
||||
),
|
||||
);
|
||||
}
|
||||
actions = actions.child(
|
||||
button(format!("sign-out-{i}"), "Sign out", Kind::Ghost, &t).on_click(cx.listener(
|
||||
move |this, _: &ClickEvent, _, cx| {
|
||||
let sign_in_progress = self
|
||||
.sign_in_target
|
||||
.as_ref()
|
||||
.filter(|a| a.uuid == uuid)
|
||||
.and_then(|_| match self.sign_in_state.as_ref() {
|
||||
Some(SignInState::Starting) => Some("Asking Microsoft…"),
|
||||
Some(SignInState::Code(_)) => Some("Waiting for browser sign-in…"),
|
||||
Some(SignInState::Working(phase)) => Some(phase.label()),
|
||||
_ => None,
|
||||
});
|
||||
let progress = sign_in_progress
|
||||
.or_else(|| self.auth_progress.get(&uuid).map(|phase| phase.label()));
|
||||
let busy = self.auth_busy.contains_key(&uuid) || sign_in_progress.is_some();
|
||||
let status = self.auth_status.get(&uuid);
|
||||
let needs_sign_in =
|
||||
status.is_some_and(|s| s.state == account::session::State::SignInRequired);
|
||||
let refresh_id = uuid.clone();
|
||||
let refresh = button(
|
||||
format!("refresh-{i}"),
|
||||
if busy {
|
||||
"Checking…"
|
||||
} else if needs_sign_in {
|
||||
"Sign in again"
|
||||
} else {
|
||||
"Refresh"
|
||||
},
|
||||
Kind::Ghost,
|
||||
&t,
|
||||
);
|
||||
actions = actions.child(if busy {
|
||||
disabled(refresh)
|
||||
} else {
|
||||
refresh.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
|
||||
if needs_sign_in {
|
||||
this.re_sign_in(&refresh_id);
|
||||
} else {
|
||||
this.refresh_account(refresh_id.clone());
|
||||
}
|
||||
cx.notify();
|
||||
}))
|
||||
});
|
||||
let sign_out = button(format!("sign-out-{i}"), "Sign out", Kind::Ghost, &t);
|
||||
actions = actions.child(if busy {
|
||||
disabled(sign_out)
|
||||
} else {
|
||||
sign_out.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
|
||||
let Some(generation) = this.begin_auth_job(&uuid) else {
|
||||
return;
|
||||
};
|
||||
let (tx, uuid) = (this.tx.clone(), uuid.clone());
|
||||
std::thread::spawn(move || {
|
||||
let mut accounts = Accounts::load();
|
||||
match accounts
|
||||
let _ = tx.send(Msg::AccountProgress(
|
||||
uuid.clone(),
|
||||
generation,
|
||||
account::session::Phase::SigningOut,
|
||||
));
|
||||
let result = accounts
|
||||
.remove(account::store(), &uuid)
|
||||
.and_then(|()| accounts.save())
|
||||
{
|
||||
Ok(()) => this.accounts = accounts,
|
||||
Err(e) => this.sign_in_state = Some(SignInState::Done(Err(e))),
|
||||
}
|
||||
this.owner = Some(this.accounts.has_owner(account::store()));
|
||||
cx.notify();
|
||||
},
|
||||
)),
|
||||
);
|
||||
.map(|()| accounts.has_owner(account::store()));
|
||||
let _ = tx.send(Msg::AccountRemoved(uuid, generation, result));
|
||||
});
|
||||
cx.notify();
|
||||
}))
|
||||
});
|
||||
n += 1;
|
||||
rows = rows.child(index_row(
|
||||
n,
|
||||
rows = rows.child(slot_row(
|
||||
Some(self.profile_heads.draw(Some(&a.uuid), 32., self.scale, &t)),
|
||||
a.name.clone(),
|
||||
Some(
|
||||
div()
|
||||
.child(tr(if plays {
|
||||
"Microsoft account · plays"
|
||||
} else {
|
||||
"Microsoft account · signed in"
|
||||
"Microsoft account"
|
||||
}))
|
||||
.child(account_status_line(status, progress, &t))
|
||||
.when(!busy, |row| {
|
||||
row.when_some(status.filter(|s| s.message != s.label()), |row, s| {
|
||||
row.child(div().child(tr(&s.message).to_string()))
|
||||
})
|
||||
})
|
||||
.into_any_element(),
|
||||
),
|
||||
Some(actions.into_any_element()),
|
||||
@@ -3486,11 +3758,12 @@ impl Launcher {
|
||||
let configured = account::client_id().is_some();
|
||||
let busy = matches!(
|
||||
self.sign_in_state,
|
||||
Some(SignInState::Starting | SignInState::Code(_))
|
||||
Some(SignInState::Starting | SignInState::Code(_) | SignInState::Working(_))
|
||||
);
|
||||
let detail = match &self.sign_in_state {
|
||||
_ if !configured => div().child(tr(account::NOT_CONFIGURED)),
|
||||
Some(SignInState::Starting) => div().child(tr("Asking Microsoft…")),
|
||||
Some(SignInState::Working(phase)) => div().child(tr(phase.label())),
|
||||
Some(SignInState::Code(code)) => div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
@@ -3514,6 +3787,19 @@ impl Launcher {
|
||||
)),
|
||||
None => div().child(tr("Add another account.")),
|
||||
};
|
||||
let detail = if let Some(target) = &self.sign_in_target {
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap(px(4.))
|
||||
.child(trf(
|
||||
"Use the Microsoft account for {name}.",
|
||||
&[("name", &target.name)],
|
||||
))
|
||||
.child(detail)
|
||||
} else {
|
||||
detail
|
||||
};
|
||||
let detail = match self.keychain.clone() {
|
||||
Some(said) if !self.accounts.accounts.is_empty() => div()
|
||||
.flex()
|
||||
@@ -3548,10 +3834,7 @@ impl Launcher {
|
||||
.child(
|
||||
button("sign-in-cancel", "Cancel", Kind::Ghost, &t).on_click(cx.listener(
|
||||
|this, _: &ClickEvent, _, cx| {
|
||||
if let Some(s) = this.sign_in.take() {
|
||||
s.cancel();
|
||||
}
|
||||
this.sign_in_state = None;
|
||||
this.cancel_sign_in();
|
||||
cx.notify();
|
||||
},
|
||||
)),
|
||||
@@ -3559,26 +3842,54 @@ impl Launcher {
|
||||
.into_any_element(),
|
||||
)
|
||||
} else if busy {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
microsoft::sign_in_button("sign-in", &t)
|
||||
button("sign-in-cancel-working", "Cancel", Kind::Ghost, &t)
|
||||
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
|
||||
this.start_sign_in();
|
||||
this.cancel_sign_in();
|
||||
cx.notify();
|
||||
}))
|
||||
.into_any_element(),
|
||||
)
|
||||
} else {
|
||||
let target = self.sign_in_target.as_ref().map(|a| a.uuid.clone());
|
||||
let targeted = target.is_some();
|
||||
// Re-signing one account names it; adding one is Microsoft's own button.
|
||||
let sign_in = if targeted {
|
||||
button("sign-in", "Sign in again", Kind::Secondary, &t)
|
||||
} else {
|
||||
microsoft::sign_in_button("sign-in", &t)
|
||||
};
|
||||
let mut actions = div().flex().gap(px(4.)).child(sign_in.on_click(cx.listener(
|
||||
move |this, _: &ClickEvent, _, cx| {
|
||||
if let Some(uuid) = &target {
|
||||
this.re_sign_in(uuid);
|
||||
} else {
|
||||
this.start_sign_in();
|
||||
}
|
||||
cx.notify();
|
||||
},
|
||||
)));
|
||||
if targeted {
|
||||
actions = actions.child(
|
||||
button("sign-in-cancel-target", "Cancel", Kind::Ghost, &t).on_click(
|
||||
cx.listener(|this, _: &ClickEvent, _, cx| {
|
||||
this.cancel_sign_in();
|
||||
cx.notify();
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
Some(actions.into_any_element())
|
||||
};
|
||||
n += 1;
|
||||
let title = if self.accounts.accounts.is_empty() {
|
||||
"Microsoft account"
|
||||
} else {
|
||||
"Another account"
|
||||
let title = match &self.sign_in_target {
|
||||
Some(target) => trf("Sign in again as {name}", &[("name", &target.name)]),
|
||||
None if self.accounts.accounts.is_empty() => tr("Microsoft account").to_owned(),
|
||||
None => tr("Another account").to_owned(),
|
||||
};
|
||||
rows = rows.child(index_row(
|
||||
n,
|
||||
tr(title),
|
||||
title,
|
||||
Some(detail.into_any_element()),
|
||||
action,
|
||||
&t,
|
||||
@@ -4505,6 +4816,36 @@ mod tests {
|
||||
assert!(!starts_now(true, false, true), "already running");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_completed_sign_in_is_handled_once_across_ui_polls() {
|
||||
use super::{sign_in_change, SignInState};
|
||||
let mut shown = None;
|
||||
let completed = SignInState::Done(Ok(horizon_launcher::account::Account {
|
||||
uuid: "11111111111111111111111111111111".into(),
|
||||
name: "TestAccount".into(),
|
||||
skin: None,
|
||||
cape: None,
|
||||
}));
|
||||
assert!(sign_in_change(&mut shown, SignInState::Starting).is_some());
|
||||
assert!(sign_in_change(&mut shown, SignInState::Starting).is_none());
|
||||
let mut account_reloads = 0;
|
||||
for _ in 0..100 {
|
||||
if matches!(
|
||||
sign_in_change(&mut shown, completed.clone()),
|
||||
Some(SignInState::Done(Ok(_)))
|
||||
) {
|
||||
account_reloads += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
account_reloads, 1,
|
||||
"completed sign-in must not restart account checks every frame"
|
||||
);
|
||||
let failed = SignInState::Done(Err("Sign-in cancelled".into()));
|
||||
assert!(sign_in_change(&mut shown, failed.clone()).is_some());
|
||||
assert!(sign_in_change(&mut shown, failed).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_app_menu_has_quit() {
|
||||
let menus = super::menus();
|
||||
|
||||
+2
-1
@@ -191,12 +191,13 @@ impl Session {
|
||||
let accounts = Accounts::load();
|
||||
let sign_in = self.sign_in.as_ref().map(|s| match s.state() {
|
||||
SignInState::Starting => json!({ "state": "starting" }),
|
||||
SignInState::Working(phase) => json!({ "state": "working", "phase": phase }),
|
||||
SignInState::Code(c) => json!({ "state": "waiting for the player", "link": c.verification_uri, "code": c.user_code }),
|
||||
SignInState::Done(Ok(a)) => json!({ "state": "signed in", "name": a.name }),
|
||||
SignInState::Done(Err(e)) => json!({ "state": "failed", "error": e }),
|
||||
});
|
||||
Ok(serde_json::to_string_pretty(&json!({
|
||||
"accounts": accounts.accounts.iter().map(|a| json!({ "name": a.name, "uuid": a.uuid })).collect::<Vec<_>>(),
|
||||
"accounts": accounts.accounts.iter().map(|a| json!({ "name": a.name, "uuid": a.uuid, "authentication": account::session::status(account::store(), &a.uuid) })).collect::<Vec<_>>(),
|
||||
"plays_as": accounts.selected().map(|a| a.name.clone()),
|
||||
"sign_in_configured": account::client_id().is_some(),
|
||||
"keychain": account::store().persistent(),
|
||||
|
||||
@@ -31,10 +31,7 @@ impl Launcher {
|
||||
|
||||
/// Closes it; it comes back with the next refused Play.
|
||||
fn close_owner_screen(&mut self) {
|
||||
if let Some(s) = self.sign_in.take() {
|
||||
s.cancel();
|
||||
}
|
||||
self.sign_in_state = None;
|
||||
self.cancel_sign_in();
|
||||
self.owner_screen_open = false;
|
||||
if !self.settings.owner_notice_seen {
|
||||
self.settings.owner_notice_seen = true;
|
||||
@@ -50,7 +47,8 @@ impl Launcher {
|
||||
let state = match &self.sign_in_state {
|
||||
_ if sign_in_preview() => Shown::Code("https://www.microsoft.com/link", "K7Q-4MXR"),
|
||||
_ if !configured => Shown::Error(tr(account::NOT_CONFIGURED)),
|
||||
Some(SignInState::Starting) => Shown::Starting,
|
||||
Some(SignInState::Starting) => Shown::Starting("Asking Microsoft…"),
|
||||
Some(SignInState::Working(phase)) => Shown::Starting(phase.label()),
|
||||
Some(SignInState::Code(code)) => Shown::Code(&code.verification_uri, &code.user_code),
|
||||
Some(SignInState::Done(Err(e))) => Shown::Error(e),
|
||||
Some(SignInState::Done(Ok(_))) | None => Shown::Idle,
|
||||
@@ -144,12 +142,9 @@ impl Launcher {
|
||||
),
|
||||
)
|
||||
}
|
||||
Shown::Starting => body.child(
|
||||
raised().child(
|
||||
shell::text(theme::night::UI, shell::c(n.ink_soft))
|
||||
.child(tr("Asking Microsoft…").to_string()),
|
||||
),
|
||||
),
|
||||
Shown::Starting(what) => body.child(raised().child(
|
||||
shell::text(theme::night::UI, shell::c(n.ink_soft)).child(tr(what).to_string()),
|
||||
)),
|
||||
Shown::Error(e) => body.child(
|
||||
raised()
|
||||
.child(shell::text(theme::night::UI, shell::c(n.danger)).child(e.to_string())),
|
||||
@@ -165,7 +160,7 @@ impl Launcher {
|
||||
.on_click(move |_, _, cx| cx.open_url(&uri)),
|
||||
)
|
||||
}
|
||||
Shown::Starting => None,
|
||||
Shown::Starting(_) => None,
|
||||
Shown::Error(_) if !configured => None,
|
||||
_ => Some(
|
||||
microsoft::sign_in_button("owner-sign-in", &t).on_click(cx.listener(
|
||||
@@ -237,7 +232,8 @@ impl Launcher {
|
||||
#[derive(Clone, Copy)]
|
||||
enum Shown<'a> {
|
||||
Idle,
|
||||
Starting,
|
||||
/// What the sign-in is doing.
|
||||
Starting(&'static str),
|
||||
/// The page and the code to enter there.
|
||||
Code(&'a str, &'a str),
|
||||
Error(&'a str),
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
//! Profile-head I/O stays on workers; every account surface shares the same pixel-sharp cache.
|
||||
use super::*;
|
||||
use gpui::{img, RenderImage};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
path::PathBuf,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
/// How long a head that fell back to the default skin waits before the real one is fetched again.
|
||||
const RETRY: Duration = Duration::from_secs(60);
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Heads(HashMap<String, Head>);
|
||||
|
||||
struct Head {
|
||||
url: Option<String>,
|
||||
pending: bool,
|
||||
face: Option<Vec<[u8; 4]>>,
|
||||
/// Set while `face` is a fallback because the skin download failed.
|
||||
retry_at: Option<Instant>,
|
||||
images: RefCell<HashMap<(u32, u32), Arc<RenderImage>>>,
|
||||
}
|
||||
|
||||
impl Heads {
|
||||
pub fn request(&mut self, accounts: &[account::Account], resources: PathBuf, tx: &Sender<Msg>) {
|
||||
self.0
|
||||
.retain(|uuid, _| accounts.iter().any(|a| &a.uuid == uuid));
|
||||
for account in accounts {
|
||||
let url = account.skin.as_ref().map(|s| s.0.clone());
|
||||
if self.0.get(&account.uuid).is_some_and(|h| {
|
||||
h.url == url
|
||||
&& (h.pending
|
||||
|| h.face.is_some() && h.retry_at.is_none_or(|t| Instant::now() < t))
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
let previous = self.0.remove(&account.uuid).and_then(|h| h.face);
|
||||
self.0.insert(
|
||||
account.uuid.clone(),
|
||||
Head {
|
||||
url: url.clone(),
|
||||
pending: true,
|
||||
face: previous,
|
||||
retry_at: None,
|
||||
images: RefCell::default(),
|
||||
},
|
||||
);
|
||||
let (account, resources, tx) = (account.clone(), resources.clone(), tx.clone());
|
||||
std::thread::spawn(move || {
|
||||
let (face, failed) = horizon_launcher::heads::profile_face(
|
||||
&account,
|
||||
&backend::data_dir().join("profile-heads"),
|
||||
&resources,
|
||||
);
|
||||
let _ = tx.send(Msg::AccountHead(account.uuid, url, face, failed));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn loaded(
|
||||
&mut self,
|
||||
uuid: &str,
|
||||
url: &Option<String>,
|
||||
face: Option<Vec<[u8; 4]>>,
|
||||
failed: bool,
|
||||
) {
|
||||
if let Some(head) = self.0.get_mut(uuid).filter(|h| &h.url == url) {
|
||||
head.pending = false;
|
||||
head.retry_at = failed.then(|| Instant::now() + RETRY);
|
||||
if face.is_some() {
|
||||
head.face = face;
|
||||
}
|
||||
head.images.get_mut().clear();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw(&self, uuid: Option<&str>, size: f32, scale: f32, t: &Theme) -> Div {
|
||||
let image = uuid.and_then(|uuid| self.0.get(uuid)).and_then(|head| {
|
||||
let face = head.face.as_ref()?;
|
||||
Some(
|
||||
head.images
|
||||
.borrow_mut()
|
||||
.entry((size.to_bits(), scale.to_bits()))
|
||||
.or_insert_with(|| art::bake(face, 8, 8, size / 8., scale))
|
||||
.clone(),
|
||||
)
|
||||
});
|
||||
let d = div()
|
||||
.flex_none()
|
||||
.size(px(size))
|
||||
.overflow_hidden()
|
||||
.bg(t.well)
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center();
|
||||
match image {
|
||||
Some(image) => d.child(img(image).size(px(size))),
|
||||
None => d.child(blocks::icon("user", size * 0.6, t.muted)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn stale_skin_results_are_ignored_and_scaled_images_are_reused() {
|
||||
let mut heads = Heads::default();
|
||||
let current = Some("current-skin".to_string());
|
||||
heads.0.insert(
|
||||
"player".into(),
|
||||
Head {
|
||||
url: current.clone(),
|
||||
pending: true,
|
||||
face: None,
|
||||
retry_at: None,
|
||||
images: RefCell::default(),
|
||||
},
|
||||
);
|
||||
heads.loaded(
|
||||
"player",
|
||||
&Some("old-skin".into()),
|
||||
Some(vec![[255, 0, 0, 255]; 64]),
|
||||
false,
|
||||
);
|
||||
assert!(heads.0["player"].face.is_none());
|
||||
heads.loaded("player", ¤t, Some(vec![[0, 0, 255, 255]; 64]), true);
|
||||
assert!(
|
||||
heads.0["player"].retry_at.is_some(),
|
||||
"a fallback face is fetched again later"
|
||||
);
|
||||
heads.loaded("player", ¤t, Some(vec![[0, 255, 0, 255]; 64]), false);
|
||||
assert!(heads.0["player"].retry_at.is_none());
|
||||
let theme = Theme::for_appearance(
|
||||
backend::Appearance::Dark,
|
||||
horizon_launcher::themes::Accent::VIOLET,
|
||||
gpui::WindowAppearance::Dark,
|
||||
);
|
||||
let _ = heads.draw(Some("player"), 32., 2., &theme);
|
||||
let key = (32f32.to_bits(), 2f32.to_bits());
|
||||
let image = heads.0["player"].images.borrow()[&key].clone();
|
||||
assert_eq!(image.size(0).width.0, 64);
|
||||
assert_eq!(image.size(0).height.0, 64);
|
||||
let _ = heads.draw(Some("player"), 32., 2., &theme);
|
||||
assert!(Arc::ptr_eq(
|
||||
&image,
|
||||
&heads.0["player"].images.borrow()[&key]
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -101,10 +101,20 @@ impl Launcher {
|
||||
.rounded(px(theme::RADIUS))
|
||||
.cursor_pointer()
|
||||
.hover(move |s| s.bg(t.plate_soft))
|
||||
.child(self.profile_heads.draw(
|
||||
match &who {
|
||||
Player::Microsoft(uuid) => Some(uuid),
|
||||
_ => None,
|
||||
},
|
||||
24.,
|
||||
self.scale,
|
||||
&t,
|
||||
))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.child(text(theme::LABEL).truncate().text_color(t.ink).child(name))
|
||||
.child(text(theme::CAPTION).text_color(t.muted).child(how)),
|
||||
|
||||
Reference in new issue
Block a user