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:
Ludovic Marie authored and GitHub committed 2026-11-01 03:16:32 +01:00
commit ba8b5b4146
24 files changed
+2061 -104

No files matched your search

+22
View File
@@ -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.
+53
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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ę."
}
+27 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
})();
+352
View File
@@ -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))
}
+396
View File
@@ -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, &notch(), &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, &notch(), &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, &notch(), &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, &notch(), &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, &notch(), &nowhere());
assert_eq!(status.state, session::State::SignInRequired);
let requests = m.lock().unwrap().log.len();
let _ = session::refresh_with(&ep, Some("test-client"), &store, &notch(), &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,
&notch(),
&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, &notch(), &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,
&notch(),
&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,
&notch(),
&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,
&notch(),
&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(&notch()),
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(&notch()),
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(&notch()),
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(&notch()),
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(&notch()),
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,
&notch(),
&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(&notch()),
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();
+9
View File
@@ -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
View File
@@ -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)?;
+12 -3
View File
@@ -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}");
}
}
+160
View File
@@ -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
View File
@@ -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
View File
@@ -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(),
+9 -13
View File
@@ -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),
+153
View File
@@ -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", &current, 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", &current, 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]
));
}
}
+10
View File
@@ -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)),