feat: account in the title bar and sign-in without pasting

This commit is contained in:
Felitendo committed 2026-09-15 17:02:40 +02:00
1 parent b045aaee56
commit 030aa8fb57
12 files changed
+796 -240

No files matched your search

+10 -7
View File
@@ -28,6 +28,7 @@ works.
| `0013-Explain-what-went-wrong-...` | The Logs tab says what a crash was and offers a fix where there is one, without a connection. |
| `0014-Browse-skins-from-...` | A Browse tab on the skin page: Ely.by's catalogue in the app, and NameMC, laby.net and crafty.gg in a window. |
| `0015-Use-the-desktop-s-file-picker-...` | File pickers on Linux are the desktop's own, such as KDE's, through the XDG desktop portal. |
| `0016-Show-the-account-in-the-title-bar-...` | With the right sidebar folded away, the Minecraft account is shown in the title bar and managed from there. |
### Offline accounts
@@ -49,11 +50,11 @@ Microsoft sign-in opens your own browser rather than a webview inside the launch
password manager, autofill and passkeys work, and you can see in the address bar that the page is
really Microsoft's.
Microsoft cannot hand the result back: the client id the launcher uses is Minecraft's own, whose
only registered redirect is a fixed page on `login.live.com`, with no loopback address for the
launcher to listen on. So the browser lands on that page with the code in the address and you copy
the address into the launcher. The webview is still one click away for anyone the browser does not
work out for.
The client id the launcher uses is Minecraft's own, with no redirect the launcher could listen on.
So the browser signs in on Microsoft's device code page, with the code already filled in, while the
launcher asks Microsoft every few seconds whether that has happened. Once it has, the account is
added and the launcher comes back to the front, without anything to paste. The window inside the
launcher is still one click away in the dialog, for when the browser does not work out.
### Ely.by accounts
@@ -80,8 +81,10 @@ Settings > Features > Sidebar.
The news section in the right sidebar folds away by clicking its heading. The right sidebar itself
folds away with the arrow button in the title bar, which upstream only shows once "Hide right
sidebar" is turned on in settings. Both remember what they were set to across restarts, and the
title bar button briefly shows a check once its state is saved. On pages that need the sidebar, such
as the mod browser, the button stays in place, greyed out.
title bar button briefly shows a check once its state is saved; the first time, a short note
explains this. On pages that need the sidebar, such as the mod browser, the button stays in place,
greyed out. While the sidebar is folded away, the
Minecraft account sits in the title bar next to the window buttons, to switch, add or remove accounts.
None of the three reach Modrinth. Preference syncing maps a fixed list of named fields in both
directions and these are not in it, so they are neither sent to your Modrinth account nor
+80 -26
View File
@@ -30,14 +30,14 @@ page headed "Sync features across devices" and would otherwise look like
it syncs. The other two are not settings entries - a heading and a title
bar button - so nothing there promises anything.
---
apps/app-frontend/src/App.vue | 153 ++++++++++++++++--
.../ui/settings/display/FeaturesSettings.vue | 28 ++++
.../src/composables/use-app-settings.ts | 3 +
packages/app-lib/src/state/settings.rs | 3 +
4 files changed, 172 insertions(+), 15 deletions(-)
apps/app-frontend/src/App.vue | 184 ++++++++++++++++--
.../ui/settings/display/FeaturesSettings.vue | 28 +++
.../src/composables/use-app-settings.ts | 4 +
packages/app-lib/src/state/settings.rs | 4 +
4 files changed, 205 insertions(+), 15 deletions(-)
diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
index d750a5f..ac8308c 100644
index d750a5f..c47484d 100644
--- a/apps/app-frontend/src/App.vue
+++ b/apps/app-frontend/src/App.vue
@@ -10,6 +10,8 @@ import {
@@ -49,7 +49,23 @@ index d750a5f..ac8308c 100644
ChevronLeftIcon,
ChevronRightIcon,
CompassIcon,
@@ -254,12 +256,41 @@ const credentials = ref()
@@ -34,6 +36,7 @@ import {
AccountSwitchOverlay,
Admonition,
Avatar,
+ Button,
ButtonLink,
commonMessages,
commonSettingsMessages,
@@ -44,6 +47,7 @@ import {
I18nDebugPanel,
IconButton,
LoadingBar,
+ NewModal,
NewsArticleCard,
NotificationPanel,
PopupNotificationPanel,
@@ -254,12 +258,46 @@ const credentials = ref()
const storedModrinthAccounts = ref([])
let credentialsRefreshId = 0
const sidebarToggled = ref(true)
@@ -72,29 +88,34 @@ index d750a5f..ac8308c 100644
+ (hide) => setSidebarCollapsed(hide),
)
+
+// Briefly shows a check on the fold button once the state is saved, so it is
+// clear the choice is remembered.
+// Briefly shows a check on the fold button once the state is saved, and the
+// first time also explains that the choice is remembered.
+const sidebarSaved = ref(false)
+let sidebarSavedTimeout
+const sidebarRememberedModal = ref(null)
+
+function setSidebarCollapsed(collapsed) {
+ const explain = !appSettings.featureFlags.sidebar_fold_explained
+ appSettings.featureFlags.right_sidebar_collapsed = collapsed
+ appSettings.featureFlags.sidebar_fold_explained = true
+ getSettings()
+ .then((settings) => {
+ settings.feature_flags.right_sidebar_collapsed = collapsed
+ settings.feature_flags.sidebar_fold_explained = true
+ return setSettings(settings)
+ })
+ .then(() => {
+ sidebarSaved.value = true
+ clearTimeout(sidebarSavedTimeout)
+ sidebarSavedTimeout = setTimeout(() => (sidebarSaved.value = false), 1600)
+ if (explain) sidebarRememberedModal.value?.show()
+ })
+ .catch(handleError)
+}
const forceSidebar = computed(
() =>
route.path.startsWith('/browse') ||
@@ -358,6 +389,21 @@ useQuery({
@@ -358,6 +396,21 @@ useQuery({
// ad and the gradient being drawn above it.
const showAd = computed(() => false)
const adConsentAvailable = computed(() => false)
@@ -116,15 +137,24 @@ index d750a5f..ac8308c 100644
providePageContext({
hierarchicalSidebarAvailable: ref(true),
showAds: showAd,
@@ -588,6 +634,16 @@ const messages = defineMessages({
@@ -588,6 +641,25 @@ const messages = defineMessages({
goBack: { id: 'app.navigation.go-back', defaultMessage: 'Go back' },
goForward: { id: 'app.navigation.go-forward', defaultMessage: 'Go forward' },
nextImage: { id: 'app.navigation.next-image', defaultMessage: 'Next image' },
+ hideSidebar: { id: 'app.navigation.hide-sidebar', defaultMessage: 'Hide sidebar' },
+ showSidebar: { id: 'app.navigation.show-sidebar', defaultMessage: 'Show sidebar' },
+ sidebarRemembered: {
+ id: 'app.navigation.sidebar-remembered',
+ defaultMessage: 'Remembered after restart',
+ sidebarRememberedTitle: {
+ id: 'app.navigation.sidebar-remembered.title',
+ defaultMessage: 'The sidebar stays how you leave it',
+ },
+ sidebarRememberedBody: {
+ id: 'app.navigation.sidebar-remembered.body',
+ defaultMessage:
+ 'Folding the sidebar away or bringing it back is saved right away, so the app opens the same way next time, even after a restart. The check on the button shows when it has been saved.',
+ },
+ sidebarRememberedDone: {
+ id: 'app.navigation.sidebar-remembered.done',
+ defaultMessage: 'Done',
+ },
+ sidebarRequired: {
+ id: 'app.navigation.sidebar-required',
@@ -133,7 +163,7 @@ index d750a5f..ac8308c 100644
updateDownloadMissingVersion: {
id: 'app.update.download-error.missing-version',
defaultMessage: 'Failed to download update: no version available',
@@ -2252,6 +2308,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
@@ -2252,6 +2324,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<ImageIcon />
</NavButton>
<NavButton
@@ -141,7 +171,7 @@ index d750a5f..ac8308c 100644
v-tooltip.right="formatMessage(messages.modrinthHosting)"
to="/hosting/manage"
:is-primary="(r) => r.path === '/hosting/manage' || r.path === '/hosting/manage/'"
@@ -2377,16 +2434,44 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
@@ -2377,16 +2450,42 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<Breadcrumbs />
</div>
<section data-tauri-drag-region class="flex shrink-0 ml-auto items-center">
@@ -162,8 +192,6 @@ index d750a5f..ac8308c 100644
+ formatMessage(
+ forceSidebar
+ ? messages.sidebarRequired
+ : sidebarSaved
+ ? messages.sidebarRemembered
+ : sidebarToggled
+ ? messages.hideSidebar
+ : messages.showSidebar,
@@ -195,7 +223,7 @@ index d750a5f..ac8308c 100644
<div class="flex mr-3">
<Suspense>
<AppActionBar />
@@ -2502,10 +2587,18 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
@@ -2502,10 +2601,18 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"
/>
<div v-if="news && news.length > 0" class="p-4 flex flex-col items-center">
@@ -217,7 +245,29 @@ index d750a5f..ac8308c 100644
<NewsArticleCard
v-for="(item, index) in news"
:key="`news-${index}`"
@@ -2743,6 +2836,36 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
@@ -2531,6 +2638,21 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<I18nDebugPanel />
<NotificationPanel :has-sidebar="sidebarVisible" />
<PopupNotificationPanel :has-sidebar="sidebarVisible" />
+ <NewModal
+ ref="sidebarRememberedModal"
+ :header="formatMessage(messages.sidebarRememberedTitle)"
+ max-width="460px"
+ >
+ <p class="m-0">{{ formatMessage(messages.sidebarRememberedBody) }}</p>
+ <template #actions>
+ <div class="flex justify-end">
+ <Button type="colored" color="brand" @click="sidebarRememberedModal?.hide()">
+ <CheckIcon />
+ {{ formatMessage(messages.sidebarRememberedDone) }}
+ </Button>
+ </div>
+ </template>
+ </NewModal>
<ErrorModal ref="errorModal" />
<MinecraftAuthErrorModal ref="minecraftAuthErrorModal" />
<MinecraftRequiredModal ref="minecraftRequiredModal" />
@@ -2743,6 +2865,38 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
display: contents;
}
@@ -238,9 +288,11 @@ index d750a5f..ac8308c 100644
+ opacity: 0;
+ }
+
+ /* The path runs from the long stroke's tip back to the short one, so it is
+ revealed from its end: the check is drawn left to right. */
+ .sidebar-saved-badge :deep(path) {
+ stroke-dasharray: 20;
+ stroke-dashoffset: 20;
+ stroke-dasharray: 24;
+ stroke-dashoffset: -24;
+ animation: sidebar-saved-draw 0.3s 0.15s ease-out forwards;
+ }
+}
@@ -336,29 +388,31 @@ index 1d3e379..ae88550 100644
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.quickInstancesTitle) }}
diff --git a/apps/app-frontend/src/composables/use-app-settings.ts b/apps/app-frontend/src/composables/use-app-settings.ts
index 0d58903..59da62b 100644
index 0d58903..28c78c3 100644
--- a/apps/app-frontend/src/composables/use-app-settings.ts
+++ b/apps/app-frontend/src/composables/use-app-settings.ts
@@ -24,6 +24,9 @@ export const DEFAULT_FEATURE_FLAGS = {
@@ -24,6 +24,10 @@ export const DEFAULT_FEATURE_FLAGS = {
friends_pending_collapsed: true,
dismissed_photosensitivity_filter_warning: false,
localhost_sign_in: false,
+ show_hosting_in_sidebar: true,
+ news_collapsed: false,
+ sidebar_fold_explained: false,
+ right_sidebar_collapsed: false,
}
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
diff --git a/packages/app-lib/src/state/settings.rs b/packages/app-lib/src/state/settings.rs
index 20601b8..2fd0d87 100644
index 20601b8..b5f2ec6 100644
--- a/packages/app-lib/src/state/settings.rs
+++ b/packages/app-lib/src/state/settings.rs
@@ -88,6 +88,9 @@ pub enum FeatureFlag {
@@ -88,6 +88,10 @@ pub enum FeatureFlag {
FriendsPendingCollapsed,
DismissedPhotosensitivityFilterWarning,
LocalhostSignIn,
+ ShowHostingInSidebar,
+ NewsCollapsed,
+ SidebarFoldExplained,
+ RightSidebarCollapsed,
}
@@ -8,30 +8,30 @@ which is the one place a player cannot use the tools they use everywhere
else: no password manager, no autofill, no passkeys, and no way to tell
by looking that the page is really Microsoft's.
The browser gets all of it. What it cannot do is hand the code back:
this client id is Minecraft's own, its only registered redirect is a
fixed page on login.live.com, and no loopback address is registered for
it, so there is nothing for the launcher to listen on. The browser
therefore lands on that page with the code in the address, and the
player copies the address over - which is what the new modal asks for,
and what `login_browser_finish` reads the code out of.
The browser gets all of it. What it cannot do is hand a code back: this
client id is Minecraft's own, and no loopback address is registered for
it. So the browser signs in on Microsoft's device code page, with the
code already filled in, while the launcher polls for the result and
comes back to the front once it is there.
Every entry point goes through it, since they all end up at
`AccountsCard.login()`. The webview is still one click away in that
modal for anyone the browser does not work out for.
---
.../src/components/ui/AccountsCard.vue | 17 +-
.../src/components/ui/MicrosoftLoginModal.vue | 213 ++++++++++++++++++
.../src/components/ui/AccountsCard.vue | 21 +-
.../src/components/ui/MicrosoftLoginModal.vue | 219 ++++++++++++++++++
.../MinecraftRequiredModal.vue | 29 +--
apps/app-frontend/src/helpers/auth.js | 25 ++
apps/app-frontend/src/helpers/auth.js | 23 ++
apps/app/build.rs | 2 +
apps/app/src/api/auth.rs | 77 +++++++
apps/app/src/api/auth.rs | 64 +++++
packages/app-lib/src/api/minecraft_auth.rs | 30 +++
packages/app-lib/src/api/mod.rs | 12 +-
7 files changed, 333 insertions(+), 42 deletions(-)
packages/app-lib/src/state/minecraft_auth.rs | 145 +++++++++++-
9 files changed, 499 insertions(+), 46 deletions(-)
create mode 100644 apps/app-frontend/src/components/ui/MicrosoftLoginModal.vue
diff --git a/apps/app-frontend/src/components/ui/AccountsCard.vue b/apps/app-frontend/src/components/ui/AccountsCard.vue
index 35c21fa..c81603c 100644
index 35c21fa..224d776 100644
--- a/apps/app-frontend/src/components/ui/AccountsCard.vue
+++ b/apps/app-frontend/src/components/ui/AccountsCard.vue
@@ -105,6 +105,7 @@
@@ -84,46 +84,64 @@ index 35c21fa..c81603c 100644
}
async function accountAdded() {
@@ -316,8 +309,8 @@ const messages = defineMessages({
defaultMessage: 'Not signed in',
},
addAccount: {
- id: 'minecraft-account.add-account',
- defaultMessage: 'Add account',
+ id: 'minecraft-account.add-microsoft-account',
+ defaultMessage: 'Add Microsoft account',
},
addOfflineAccount: {
id: 'minecraft-account.add-offline-account',
diff --git a/apps/app-frontend/src/components/ui/MicrosoftLoginModal.vue b/apps/app-frontend/src/components/ui/MicrosoftLoginModal.vue
new file mode 100644
index 0000000..283b89c
index 0000000..d587736
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/MicrosoftLoginModal.vue
@@ -0,0 +1,213 @@
@@ -0,0 +1,219 @@
+<template>
+ <NewModal ref="modal" :header="formatMessage(messages.header)" max-width="520px" width="100%">
+ <NewModal
+ ref="modal"
+ :header="formatMessage(messages.header)"
+ max-width="480px"
+ width="100%"
+ :on-hide="stop"
+ >
+ <div class="flex flex-col gap-4">
+ <p class="m-0 leading-tight text-secondary">
+ {{ formatMessage(messages.description) }}
+ </p>
+
+ <ol class="m-0 flex list-decimal flex-col gap-1 pl-5 leading-tight text-secondary">
+ <li>{{ formatMessage(messages.stepSignIn) }}</li>
+ <li>{{ formatMessage(messages.stepCopy) }}</li>
+ </ol>
+ <div v-if="code" class="flex flex-col items-center gap-1 rounded-2xl bg-surface-2 px-4 py-3">
+ <span class="text-sm text-secondary">{{ formatMessage(messages.codeLabel) }}</span>
+ <div class="flex items-center gap-2">
+ <span class="select-all font-mono text-2xl font-bold tracking-widest text-contrast">
+ {{ code.user_code }}
+ </span>
+ <IconButton
+ v-tooltip="formatMessage(messages.copyCode)"
+ type="quiet"
+ size="sm"
+ :label="formatMessage(messages.copyCode)"
+ @click="copyCode"
+ >
+ <CheckIcon v-if="copied" />
+ <CopyIcon v-else />
+ </IconButton>
+ </div>
+ </div>
+
+ <form class="flex flex-col gap-2" @submit.prevent="submit">
+ <label class="font-semibold text-contrast" for="microsoft-login-redirect">
+ {{ formatMessage(messages.addressLabel) }}
+ </label>
+ <Input
+ id="microsoft-login-redirect"
+ v-model="redirect"
+ :icon="LinkIcon"
+ placeholder="https://login.live.com/oauth20_desktop.srf?code=..."
+ :error="!!error"
+ autocapitalize="none"
+ autocorrect="off"
+ :spellcheck="false"
+ class="w-full"
+ />
+ <p v-if="error" class="m-0 text-sm leading-tight text-red">{{ error }}</p>
+ </form>
+ <p v-else class="m-0 flex items-center gap-2 leading-tight text-secondary">
+ <SpinnerIcon aria-hidden="true" class="animate-spin" />
+ {{ formatMessage(code ? messages.waiting : messages.opening) }}
+ </p>
+
+ <button
+ class="button-base m-0 cursor-pointer border-0 bg-transparent p-0 text-left text-sm text-secondary underline"
+ type="button"
+ :disabled="submitting"
+ @click="useBuiltInWindow"
+ >
+ {{ formatMessage(messages.useBuiltInWindow) }}
@@ -136,45 +154,38 @@ index 0000000..283b89c
+ <XIcon aria-hidden="true" />
+ {{ formatMessage(commonMessages.cancelButton) }}
+ </Button>
+ <Button
+ native-type="button"
+ :disabled="submitting || opening"
+ @click="openBrowser"
+ >
+ <Button native-type="button" :disabled="opening" @click="openBrowser">
+ <SpinnerIcon v-if="opening" aria-hidden="true" class="animate-spin" />
+ <ExternalIcon v-else aria-hidden="true" />
+ {{ formatMessage(messages.openAgain) }}
+ </Button>
+ <Button
+ type="colored"
+ color="brand"
+ native-type="button"
+ :disabled="submitting || !redirect.trim()"
+ @click="submit"
+ >
+ <SpinnerIcon v-if="submitting" aria-hidden="true" class="animate-spin" />
+ <LogInIcon v-else aria-hidden="true" />
+ {{ formatMessage(messages.finish) }}
+ </Button>
+ </div>
+ </template>
+ </NewModal>
+</template>
+
+<script setup lang="ts">
+import { ExternalIcon, LinkIcon, LogInIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
+import { CheckIcon, CopyIcon, ExternalIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
+import {
+ Button,
+ commonMessages,
+ defineMessages,
+ Input,
+ IconButton,
+ NewModal,
+ useVIntl,
+} from '@modrinth/ui'
+import { ref } from 'vue'
+
+import { handleSevereError } from '@/composables/use-error.js'
+import { login as builtInLogin, login_browser_begin, login_browser_finish } from '@/helpers/auth'
+import { login as builtInLogin, login_device_begin, login_device_poll } from '@/helpers/auth'
+
+type DeviceCode = {
+ user_code: string
+ device_code: string
+ verification_uri: string
+ interval: number
+ expires_in: number
+}
+
+const { formatMessage } = useVIntl()
+
@@ -183,60 +194,71 @@ index 0000000..283b89c
+}>()
+
+const modal = ref<InstanceType<typeof NewModal>>()
+const redirect = ref('')
+const code = ref<DeviceCode | null>(null)
+const error = ref('')
+const opening = ref(false)
+const submitting = ref(false)
+let flow: unknown = null
+const copied = ref(false)
+
+async function show(event?: MouseEvent) {
+ redirect.value = ''
+ error.value = ''
+ submitting.value = false
+// Bumped whenever the sign-in starts over or is given up on, so that a poll
+// from before does not carry on.
+let attempt = 0
+let pollTimeout: ReturnType<typeof setTimeout> | undefined
+
+function show(event?: MouseEvent) {
+ modal.value?.show(event)
+ await openBrowser()
+ void openBrowser()
+}
+
+function stop() {
+ attempt++
+ clearTimeout(pollTimeout)
+}
+
+async function openBrowser() {
+ if (opening.value) return
+
+ stop()
+ const current = attempt
+ code.value = null
+ error.value = ''
+ copied.value = false
+ opening.value = true
+ error.value = ''
+
+ try {
+ flow = await login_browser_begin()
+ const started = (await login_device_begin()) as DeviceCode
+ if (current !== attempt) return
+ code.value = started
+ schedulePoll(current, started)
+ } catch (e) {
+ flow = null
+ error.value = messageOf(e, messages.openError)
+ if (current === attempt) error.value = messageOf(e, messages.openError)
+ } finally {
+ opening.value = false
+ if (current === attempt) opening.value = false
+ }
+}
+
+async function submit() {
+ if (submitting.value) return
+
+ const value = redirect.value.trim()
+ if (!value) return
+
+ if (!flow) {
+ error.value = formatMessage(messages.noFlow)
+ return
+function schedulePoll(current: number, started: DeviceCode) {
+ pollTimeout = setTimeout(() => void poll(current, started), started.interval * 1000)
+}
+
+ submitting.value = true
+ error.value = ''
+
+async function poll(current: number, started: DeviceCode) {
+ try {
+ const account = await login_browser_finish(value, flow)
+ redirect.value = ''
+ modal.value?.hide()
+ const account = await login_device_poll(started.device_code)
+ if (account) {
+ // The account exists now, so it is announced even if the dialog was
+ // closed in the meantime.
+ if (current === attempt) modal.value?.hide()
+ emit('created', account)
+ } catch (e) {
+ error.value = messageOf(e, messages.genericError)
+ } finally {
+ submitting.value = false
+ } else if (current === attempt) {
+ schedulePoll(current, started)
+ }
+ } catch (e) {
+ if (current === attempt) error.value = messageOf(e, messages.genericError)
+ }
+}
+
+async function copyCode() {
+ if (!code.value) return
+ await navigator.clipboard.writeText(code.value.user_code)
+ copied.value = true
+ setTimeout(() => (copied.value = false), 1500)
+}
+
+// The window the launcher opens itself, for when the browser will not do.
@@ -262,29 +284,28 @@ index 0000000..283b89c
+ description: {
+ id: 'app.microsoft-login.description',
+ defaultMessage:
+ 'Your browser has opened, so your password manager and passkeys work as they do everywhere else. Microsoft cannot hand the result back to the launcher on its own, so it has to be copied over.',
+ 'Sign in to Microsoft in the browser that just opened, where your password manager and passkeys work as usual. Once you are done, you are brought back here.',
+ },
+ stepSignIn: {
+ id: 'app.microsoft-login.step-sign-in',
+ defaultMessage: 'Sign in to Microsoft in the browser.',
+ codeLabel: {
+ id: 'app.microsoft-login.code-label',
+ defaultMessage: 'If Microsoft asks for a code, enter',
+ },
+ stepCopy: {
+ id: 'app.microsoft-login.step-copy',
+ defaultMessage:
+ 'You will land on a blank page. Copy its whole address from the address bar and paste it below.',
+ copyCode: {
+ id: 'app.microsoft-login.copy-code',
+ defaultMessage: 'Copy code',
+ },
+ addressLabel: {
+ id: 'app.microsoft-login.address-label',
+ defaultMessage: 'Address the browser finished at',
+ opening: {
+ id: 'app.microsoft-login.opening',
+ defaultMessage: 'Opening your browser…',
+ },
+ waiting: {
+ id: 'app.microsoft-login.waiting',
+ defaultMessage: 'Waiting for you to sign in…',
+ },
+ openAgain: {
+ id: 'app.microsoft-login.open-again',
+ defaultMessage: 'Open browser again',
+ },
+ finish: {
+ id: 'app.microsoft-login.finish',
+ defaultMessage: 'Sign in',
+ },
+ useBuiltInWindow: {
+ id: 'app.microsoft-login.use-built-in-window',
+ defaultMessage: 'Trouble with the browser? Sign in in a window here instead.',
@@ -293,10 +314,6 @@ index 0000000..283b89c
+ id: 'app.microsoft-login.open-error',
+ defaultMessage: 'Could not open a browser to sign in with.',
+ },
+ noFlow: {
+ id: 'app.microsoft-login.no-flow',
+ defaultMessage: 'Open the browser again before signing in.',
+ },
+ genericError: {
+ id: 'app.microsoft-login.generic-error',
+ defaultMessage: 'Could not sign in to Microsoft.',
@@ -369,56 +386,54 @@ index 7781cee..341ccdc 100644
function addOfflineAccount(event: MouseEvent) {
diff --git a/apps/app-frontend/src/helpers/auth.js b/apps/app-frontend/src/helpers/auth.js
index 57580ff..c25af95 100644
index 57580ff..9b2408e 100644
--- a/apps/app-frontend/src/helpers/auth.js
+++ b/apps/app-frontend/src/helpers/auth.js
@@ -33,6 +33,31 @@ export async function login() {
@@ -33,6 +33,29 @@ export async function login() {
return await invoke('plugin:auth|login')
}
+/**
+ * Starts a Microsoft sign-in in the default browser.
+ *
+ * Opens the browser and returns the flow to hand back to
+ * {@link login_browser_finish} together with the address the browser ends up
+ * at. Unlike the window the launcher opens itself, the browser has the
+ * player's password manager, autofill and passkeys.
+ * Opens Microsoft's sign-in page with the code already filled in. Unlike the
+ * window the launcher opens itself, the browser has the player's password
+ * manager, autofill and passkeys.
+ *
+ * @returns {Promise<object>} the login flow to finish with
+ * @returns {Promise<object>} the code, to show and to poll {@link login_device_poll} with
+ */
+export async function login_browser_begin() {
+ return await invoke('plugin:auth|login_browser_begin')
+export async function login_device_begin() {
+ return await invoke('plugin:auth|login_device_begin')
+}
+
+/**
+ * Finishes a browser sign-in.
+ * Checks on a browser sign-in.
+ *
+ * @param {string} redirect the address the browser finished at, or the code in it
+ * @param {object} flow the flow returned by {@link login_browser_begin}
+ * @returns {Promise<Credential>}
+ * @param {string} deviceCode the `device_code` from {@link login_device_begin}
+ * @returns {Promise<Credential | null>} the new account, or null while the player is still signing in
+ */
+export async function login_browser_finish(redirect, flow) {
+ return await invoke('plugin:auth|login_browser_finish', { redirect, flow })
+export async function login_device_poll(deviceCode) {
+ return await invoke('plugin:auth|login_device_poll', { deviceCode })
+}
+
/**
* Adds an offline account with the given username and makes it the active one.
*
diff --git a/apps/app/build.rs b/apps/app/build.rs
index 0f62dd8..8562a68 100644
index 0f62dd8..919c91a 100644
--- a/apps/app/build.rs
+++ b/apps/app/build.rs
@@ -14,6 +14,8 @@ fn main() {
.commands(&[
"check_reachable",
"login",
+ "login_browser_begin",
+ "login_browser_finish",
+ "login_device_begin",
+ "login_device_poll",
"login_offline",
"login_ely",
"remove_user",
diff --git a/apps/app/src/api/auth.rs b/apps/app/src/api/auth.rs
index dea07b2..5c3648c 100644
index dea07b2..eab604d 100644
--- a/apps/app/src/api/auth.rs
+++ b/apps/app/src/api/auth.rs
@@ -2,6 +2,7 @@ use crate::api::Result;
@@ -433,12 +448,12 @@ index dea07b2..5c3648c 100644
.invoke_handler(tauri::generate_handler![
check_reachable,
login,
+ login_browser_begin,
+ login_browser_finish,
+ login_device_begin,
+ login_device_poll,
login_offline,
login_ely,
remove_user,
@@ -88,6 +91,80 @@ pub async fn login<R: Runtime>(
@@ -88,6 +91,67 @@ pub async fn login<R: Runtime>(
Ok(None)
}
@@ -449,19 +464,26 @@ index dea07b2..5c3648c 100644
+/// cannot sign in the way they sign in everywhere else. Their own browser has
+/// all of it.
+///
+/// What it cannot do is hand the code back. This client id's redirect is a
+/// fixed page on login.live.com - no loopback address is registered for it, so
+/// there is nothing for the launcher to listen on - and the browser lands
+/// there with the code in the address. The player copies that address over,
+/// which is what [`login_browser_finish`] takes.
+/// This client id has no redirect the launcher could listen on, so the browser
+/// cannot hand a code back. It signs in on Microsoft's device code page
+/// instead, with the code already filled in, while [`login_device_poll`] asks
+/// Microsoft whether that has happened yet.
+#[tauri::command]
+pub async fn login_browser_begin<R: Runtime>(
+pub async fn login_device_begin<R: Runtime>(
+ app: tauri::AppHandle<R>,
+) -> Result<MinecraftLoginFlow> {
+ let flow = minecraft_auth::begin_login().await?;
+) -> Result<MinecraftDeviceCode> {
+ let code = minecraft_auth::begin_device_login().await?;
+
+ let mut url = url::Url::parse(&code.verification_uri).map_err(|_| {
+ theseus::ErrorKind::OtherError(
+ "Error parsing the sign-in address".to_string(),
+ )
+ .as_error()
+ })?;
+ url.query_pairs_mut().append_pair("otc", &code.user_code);
+
+ app.opener()
+ .open_url(&flow.auth_request_uri, None::<String>)
+ .open_url(url.as_str(), None::<String>)
+ .map_err(|error| {
+ theseus::ErrorKind::OtherError(format!(
+ "Could not open a browser to sign in with: {error}"
@@ -469,58 +491,79 @@ index dea07b2..5c3648c 100644
+ .as_error()
+ })?;
+
+ Ok(flow)
+ Ok(code)
+}
+
+/// Finishes a browser sign-in with the address the browser ended up at.
+///
+/// Takes the whole address, since that is what a player can select and copy,
+/// and a bare code as well, since that is what someone who knows what they are
+/// looking at will paste.
+/// Checks on a sign-in started with [`login_device_begin`]: `None` until the
+/// player has finished it in the browser, after which the launcher comes back
+/// to the front.
+#[tauri::command]
+pub async fn login_browser_finish(
+ redirect: String,
+ flow: MinecraftLoginFlow,
+) -> Result<Credentials> {
+ let redirect = redirect.trim();
+pub async fn login_device_poll<R: Runtime>(
+ app: tauri::AppHandle<R>,
+ device_code: String,
+) -> Result<Option<Credentials>> {
+ let credentials = minecraft_auth::poll_device_login(&device_code).await?;
+
+ let code = url::Url::parse(redirect)
+ .ok()
+ .and_then(|url| {
+ url.query_pairs()
+ .find(|(key, _)| key == "code")
+ .map(|(_, code)| code.into_owned())
+ })
+ .or_else(|| {
+ // Not a URL, so treat it as the code itself - but only if it looks
+ // like one, rather than sending whatever was in the clipboard.
+ (!redirect.is_empty()
+ && redirect.len() < 2048
+ && redirect.bytes().all(|byte| {
+ byte.is_ascii_alphanumeric()
+ || byte == b'.'
+ || byte == b'-'
+ || byte == b'_'
+ }))
+ .then(|| redirect.to_owned())
+ })
+ .ok_or_else(|| {
+ theseus::ErrorKind::InputError(
+ "That does not look like the address the browser finished at. \
+ Copy the whole address out of the address bar."
+ .to_string(),
+ )
+ .as_error()
+ })?;
+ if credentials.is_some()
+ && let Some(window) = app.get_webview_window("main")
+ {
+ // Best effort: a desktop may keep a window in the background from
+ // taking focus, in which case it at least asks for attention.
+ let _ = window.unminimize();
+ let _ = window.set_focus();
+ let _ = window
+ .request_user_attention(Some(UserAttentionType::Informational));
+ }
+
+ Ok(minecraft_auth::finish_login(&code, flow).await?)
+ Ok(credentials)
+}
+
/// Adds an offline account with the given username and makes it active.
#[tauri::command]
pub async fn login_offline(username: String) -> Result<Credentials> {
diff --git a/packages/app-lib/src/api/minecraft_auth.rs b/packages/app-lib/src/api/minecraft_auth.rs
index 2d18da3..fba473f 100644
--- a/packages/app-lib/src/api/minecraft_auth.rs
+++ b/packages/app-lib/src/api/minecraft_auth.rs
@@ -47,6 +47,36 @@ pub async fn finish_login(
Ok(credentials)
}
+/// Starts a sign-in in the player's own browser. See [`poll_device_login`].
+#[tracing::instrument]
+pub async fn begin_device_login()
+-> crate::Result<crate::state::MinecraftDeviceCode> {
+ crate::state::login_device_begin().await
+}
+
+/// Checks on a sign-in started with [`begin_device_login`]: `None` until the
+/// player has finished it in the browser.
+#[tracing::instrument(skip(device_code))]
+pub async fn poll_device_login(
+ device_code: &str,
+) -> crate::Result<Option<Credentials>> {
+ let state = State::get().await?;
+
+ let credentials =
+ crate::state::login_device_poll(device_code, &state.pool).await?;
+
+ if credentials.is_some()
+ && let Err(error) =
+ crate::onboarding_checklist::mark_logged_into_minecraft().await
+ {
+ tracing::warn!(
+ "Failed to mark Minecraft login in onboarding checklist: {error}"
+ );
+ }
+
+ Ok(credentials)
+}
+
/// Creates an offline account for `username`, or reuses the existing one, and
/// makes it the active account.
///
diff --git a/packages/app-lib/src/api/mod.rs b/packages/app-lib/src/api/mod.rs
index 320112f..743d5e7 100644
index 320112f..ba6c342 100644
--- a/packages/app-lib/src/api/mod.rs
+++ b/packages/app-lib/src/api/mod.rs
@@ -28,12 +28,12 @@ pub mod data {
@@ -533,12 +576,194 @@ index 320112f..743d5e7 100644
- SearchResults, SearchResultsV3, Settings, SharedInstanceAttachment,
- SharedInstanceRole, TeamMember, Theme, User, UserFriend, Version,
- WindowSize,
+ JavaVersion, LinkedModpackInfo, MemorySettings, MinecraftLoginFlow,
+ ModLoader, ModrinthCredentials, OnboardingChecklist, Organization,
+ OwnerType, ProcessMetadata, Project, ProjectType, ProjectV3,
+ SearchResult, SearchResults, SearchResultsV3, Settings,
+ JavaVersion, LinkedModpackInfo, MemorySettings, MinecraftDeviceCode,
+ MinecraftLoginFlow, ModLoader, ModrinthCredentials, OnboardingChecklist,
+ Organization, OwnerType, ProcessMetadata, Project, ProjectType,
+ ProjectV3, SearchResult, SearchResults, SearchResultsV3, Settings,
+ SharedInstanceAttachment, SharedInstanceRole, TeamMember, Theme, User,
+ UserFriend, Version, WindowSize,
};
pub use ariadne::users::UserStatus;
pub use modrinth_content_management::{
diff --git a/packages/app-lib/src/state/minecraft_auth.rs b/packages/app-lib/src/state/minecraft_auth.rs
index 25c6dfb..4c87e82 100644
--- a/packages/app-lib/src/state/minecraft_auth.rs
+++ b/packages/app-lib/src/state/minecraft_auth.rs
@@ -37,6 +37,7 @@ use uuid::Uuid;
pub enum MinecraftAuthStep {
GetDeviceToken,
SisuAuthenticate,
+ GetDeviceCode,
GetOAuthToken,
RefreshOAuthToken,
SisuAuthorize,
@@ -108,6 +109,16 @@ pub struct MinecraftLoginFlow {
pub auth_request_uri: String,
}
+/// A sign-in in the player's own browser, on Microsoft's device code page.
+#[derive(Serialize, Deserialize, Debug)]
+pub struct MinecraftDeviceCode {
+ pub user_code: String,
+ pub device_code: String,
+ pub verification_uri: String,
+ pub interval: u64,
+ pub expires_in: u64,
+}
+
#[tracing::instrument]
pub async fn login_begin(
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
@@ -144,13 +155,37 @@ pub async fn login_finish(
code: &str,
flow: MinecraftLoginFlow,
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
+) -> crate::Result<Credentials> {
+ let oauth_token = oauth_token(code, &flow.verifier).await?;
+ login_with_oauth_token(Some(&flow.session_id), oauth_token, exec).await
+}
+
+/// Checks on a sign-in in the player's own browser: `None` until they have
+/// finished it there.
+#[tracing::instrument(skip(device_code))]
+pub async fn login_device_poll(
+ device_code: &str,
+ exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
+) -> crate::Result<Option<Credentials>> {
+ match oauth_device_token(device_code).await? {
+ Some(oauth_token) => login_with_oauth_token(None, oauth_token, exec)
+ .await
+ .map(Some),
+ None => Ok(None),
+ }
+}
+
+/// Turns a Microsoft token into a Minecraft account and saves it.
+async fn login_with_oauth_token(
+ session_id: Option<&str>,
+ oauth_token: RequestWithDate<OAuthToken>,
+ exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
) -> crate::Result<Credentials> {
let (pair, _) =
DeviceTokenPair::refresh_and_get_device_token(Utc::now(), exec).await?;
- let oauth_token = oauth_token(code, &flow.verifier).await?;
let sisu_authorize = sisu_authorize(
- Some(&flow.session_id),
+ session_id,
&oauth_token.value.access_token,
&pair.token.token,
&pair.key,
@@ -1273,6 +1308,112 @@ async fn oauth_token(
})
}
+/// Starts a sign-in on Microsoft's device code page.
+#[tracing::instrument]
+pub async fn login_device_begin() -> crate::Result<MinecraftDeviceCode> {
+ let mut query = HashMap::new();
+ query.insert("client_id", MICROSOFT_CLIENT_ID);
+ query.insert("scope", REQUESTED_SCOPE);
+ query.insert("response_type", "device_code");
+
+ let res = auth_retry(|| {
+ INSECURE_REQWEST_CLIENT
+ .post("https://login.live.com/oauth20_connect.srf")
+ .header("Accept", "application/json")
+ .form(&query)
+ .send()
+ })
+ .await
+ .map_err(|source| MinecraftAuthenticationError::Request {
+ source,
+ step: MinecraftAuthStep::GetDeviceCode,
+ })?;
+
+ let status = res.status();
+ let text = res.text().await.map_err(|source| {
+ MinecraftAuthenticationError::Request {
+ source,
+ step: MinecraftAuthStep::GetDeviceCode,
+ }
+ })?;
+
+ let body = serde_json::from_str(&text).map_err(|source| {
+ MinecraftAuthenticationError::DeserializeResponse {
+ source,
+ raw: text,
+ step: MinecraftAuthStep::GetDeviceCode,
+ status_code: status,
+ }
+ })?;
+
+ Ok(body)
+}
+
+/// The token for a device code sign-in, or `None` while the player is still
+/// signing in.
+#[tracing::instrument(skip(device_code))]
+async fn oauth_device_token(
+ device_code: &str,
+) -> crate::Result<Option<RequestWithDate<OAuthToken>>> {
+ let mut query = HashMap::new();
+ query.insert("client_id", MICROSOFT_CLIENT_ID);
+ query.insert("device_code", device_code);
+ query.insert("grant_type", "urn:ietf:params:oauth:grant-type:device_code");
+
+ // Not retried: it is asked again every few seconds anyway.
+ let res = INSECURE_REQWEST_CLIENT
+ .post("https://login.live.com/oauth20_token.srf")
+ .header("Accept", "application/json")
+ .form(&query)
+ .send()
+ .await
+ .map_err(|source| MinecraftAuthenticationError::Request {
+ source,
+ step: MinecraftAuthStep::GetOAuthToken,
+ })?;
+
+ let status = res.status();
+ let current_date = get_date_header(res.headers());
+ let text = res.text().await.map_err(|source| {
+ MinecraftAuthenticationError::Request {
+ source,
+ step: MinecraftAuthStep::GetOAuthToken,
+ }
+ })?;
+
+ if !status.is_success()
+ && let Ok(response) = serde_json::from_str::<OAuthErrorResponse>(&text)
+ {
+ let message = match response.error.as_str() {
+ "authorization_pending" | "slow_down" => return Ok(None),
+ "expired_token" => {
+ "The sign-in took too long. Open the browser again to start over."
+ }
+ "authorization_declined" | "access_denied" => {
+ "The sign-in was cancelled in the browser."
+ }
+ _ => "",
+ };
+ if !message.is_empty() {
+ return Err(ErrorKind::OtherError(message.to_string()).into());
+ }
+ }
+
+ let body = serde_json::from_str(&text).map_err(|source| {
+ MinecraftAuthenticationError::DeserializeResponse {
+ source,
+ raw: text,
+ step: MinecraftAuthStep::GetOAuthToken,
+ status_code: status,
+ }
+ })?;
+
+ Ok(Some(RequestWithDate {
+ date: current_date,
+ value: body,
+ }))
+}
+
#[tracing::instrument]
async fn oauth_refresh(
refresh_token: &str,
@@ -11,10 +11,10 @@ Subject: [PATCH] Round the window corners on Linux
4 files changed, 53 insertions(+), 1 deletion(-)
diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
index ac8308c..d681a14 100644
index c47484d..556ef25 100644
--- a/apps/app-frontend/src/App.vue
+++ b/apps/app-frontend/src/App.vue
@@ -540,6 +540,16 @@ watch([os, isFullscreen], ([osName, fullscreen]) => {
@@ -547,6 +547,16 @@ watch([os, isFullscreen], ([osName, fullscreen]) => {
document.documentElement.classList.toggle('mac-traffic-lights', osName === 'MacOS' && !fullscreen)
})
@@ -11,10 +11,10 @@ Subject: [PATCH] Scroll with the middle mouse button
create mode 100644 apps/app-frontend/src/helpers/autoscroll.ts
diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
index d681a14..2102c4c 100644
index 556ef25..0ffddff 100644
--- a/apps/app-frontend/src/App.vue
+++ b/apps/app-frontend/src/App.vue
@@ -124,6 +124,7 @@ import {
@@ -126,6 +126,7 @@ import {
} from '@/helpers/ads.js'
import { debugAnalytics, initAnalytics, trackEvent } from '@/helpers/analytics'
import { check_reachable } from '@/helpers/auth.js'
@@ -22,7 +22,7 @@ index d681a14..2102c4c 100644
import { get_user, get_user_many, get_version } from '@/helpers/cache.js'
import { gameSettingsQueryOptions } from '@/helpers/game-options'
import { install_create_modpack_instance, install_get_modpack_preview } from '@/helpers/install'
@@ -600,11 +601,18 @@ onMounted(async () => {
@@ -607,11 +608,18 @@ onMounted(async () => {
document.querySelector('body').addEventListener('auxclick', handleAuxClick)
document.querySelector('body').addEventListener('contextmenu', handleContextMenu)
document.addEventListener('fullscreenchange', handleFullscreenChange)
@@ -844,7 +844,7 @@ index 8240b8b..bcd79ec 100644
<div v-if="!currentUser" class="sticky w-full bottom-0 z-20 p-4 pt-0">
<div
diff --git a/apps/app/build.rs b/apps/app/build.rs
index 8562a68..297f383 100644
index 919c91a..1e44522 100644
--- a/apps/app/build.rs
+++ b/apps/app/build.rs
@@ -27,6 +27,23 @@ fn main() {
@@ -1416,7 +1416,7 @@ index 0000000..574183b
+ Ok(bytes.to_vec())
+}
diff --git a/packages/app-lib/src/api/mod.rs b/packages/app-lib/src/api/mod.rs
index 743d5e7..ade3ca8 100644
index ba6c342..a175675 100644
--- a/packages/app-lib/src/api/mod.rs
+++ b/packages/app-lib/src/api/mod.rs
@@ -1,5 +1,6 @@
@@ -1427,10 +1427,10 @@ index 743d5e7..ade3ca8 100644
pub mod handler;
pub mod instance;
diff --git a/packages/app-lib/src/state/minecraft_auth.rs b/packages/app-lib/src/state/minecraft_auth.rs
index 25c6dfb..ab85457 100644
index 4c87e82..904d1d8 100644
--- a/packages/app-lib/src/state/minecraft_auth.rs
+++ b/packages/app-lib/src/state/minecraft_auth.rs
@@ -957,12 +957,14 @@ impl Serialize for Credentials {
@@ -992,12 +992,14 @@ impl Serialize for Credentials {
),
};
@@ -187,12 +187,12 @@ index ae88550..9a9e8ef 100644
<h2 class="m-0 text-xl font-semibold text-contrast">
{{ formatMessage(messages.playPageTitle) }}
diff --git a/apps/app-frontend/src/composables/use-app-settings.ts b/apps/app-frontend/src/composables/use-app-settings.ts
index 59da62b..5340ad2 100644
index 28c78c3..e0c0a89 100644
--- a/apps/app-frontend/src/composables/use-app-settings.ts
+++ b/apps/app-frontend/src/composables/use-app-settings.ts
@@ -27,6 +27,7 @@ export const DEFAULT_FEATURE_FLAGS = {
show_hosting_in_sidebar: true,
@@ -28,6 +28,7 @@ export const DEFAULT_FEATURE_FLAGS = {
news_collapsed: false,
sidebar_fold_explained: false,
right_sidebar_collapsed: false,
+ universal_skins: true,
}
@@ -214,7 +214,7 @@ index f6e99d3..f06e812 100644
export async function showInstanceInFolder(instanceId) {
const fullPath = await get_full_path(instanceId)
diff --git a/apps/app/build.rs b/apps/app/build.rs
index 297f383..02c30c0 100644
index 1e44522..f5af028 100644
--- a/apps/app/build.rs
+++ b/apps/app/build.rs
@@ -399,6 +399,7 @@ fn main() {
@@ -2580,12 +2580,12 @@ index 7418675..a25a7fd 100644
pub(crate) async fn relocate_tree(from: &Path, to: &Path) -> crate::Result<()> {
if !fs::try_exists(from).await? {
diff --git a/packages/app-lib/src/state/settings.rs b/packages/app-lib/src/state/settings.rs
index 2fd0d87..673dfaa 100644
index b5f2ec6..69fc7ff 100644
--- a/packages/app-lib/src/state/settings.rs
+++ b/packages/app-lib/src/state/settings.rs
@@ -91,6 +91,7 @@ pub enum FeatureFlag {
ShowHostingInSidebar,
@@ -92,6 +92,7 @@ pub enum FeatureFlag {
NewsCollapsed,
SidebarFoldExplained,
RightSidebarCollapsed,
+ UniversalSkins,
}
@@ -440,7 +440,7 @@ index 638386d..3cccc8a 100644
function normalizeInstallProgress(value: unknown): WireObject {
diff --git a/apps/app/build.rs b/apps/app/build.rs
index 02c30c0..fc78fe1 100644
index f5af028..f98395e 100644
--- a/apps/app/build.rs
+++ b/apps/app/build.rs
@@ -110,7 +110,9 @@ fn main() {
@@ -880,7 +880,7 @@ index e2a59fd..4fcddea 100644
})
</script>
diff --git a/apps/app/build.rs b/apps/app/build.rs
index fc78fe1..4553d48 100644
index f98395e..0fb53a5 100644
--- a/apps/app/build.rs
+++ b/apps/app/build.rs
@@ -44,6 +44,17 @@ fn main() {
@@ -2075,7 +2075,7 @@ index 0000000..9a6f7e8
+ }
+}
diff --git a/packages/app-lib/src/api/mod.rs b/packages/app-lib/src/api/mod.rs
index ade3ca8..566810b 100644
index a175675..4daf723 100644
--- a/packages/app-lib/src/api/mod.rs
+++ b/packages/app-lib/src/api/mod.rs
@@ -1,5 +1,6 @@
@@ -976,7 +976,7 @@ index bcd79ec..9a10f0e 100644
</div>
diff --git a/apps/app/build.rs b/apps/app/build.rs
index 4553d48..90e22ef 100644
index 0fb53a5..f01ba91 100644
--- a/apps/app/build.rs
+++ b/apps/app/build.rs
@@ -44,6 +44,19 @@ fn main() {
@@ -1165,7 +1165,7 @@ index 8429e4c..04eedbd 100644
.plugin(api::onboarding_checklist::init())
.plugin(api::import::init())
diff --git a/packages/app-lib/src/api/mod.rs b/packages/app-lib/src/api/mod.rs
index 566810b..a6f770d 100644
index 4daf723..e64a606 100644
--- a/packages/app-lib/src/api/mod.rs
+++ b/packages/app-lib/src/api/mod.rs
@@ -17,6 +17,7 @@ pub mod process;
@@ -0,0 +1,268 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
Date: Tue, 15 Sep 2026 15:14:28 +0200
Subject: [PATCH] Show the account in the title bar when the sidebar is folded
The Minecraft account lives in the right sidebar, so folding the sidebar
away took with it any way to see which account is selected or to switch
to another. While the sidebar is folded, the account now sits in the
title bar next to the window buttons: its head and name, opening a menu
to switch accounts, add one or remove one.
The menu stands in for the sidebar's account card rather than being a
second one. That card stays mounted while the sidebar is hidden, so the
selection, the avatar and the sign-in dialogs are the card's own and
stay in step with it, and a dialog opened from the menu outlives the
menu closing.
---
apps/app-frontend/src/App.vue | 8 +
.../src/components/ui/AccountsCard.vue | 32 ++--
.../components/ui/TitleBarAccountSwitcher.vue | 163 ++++++++++++++++++
3 files changed, 191 insertions(+), 12 deletions(-)
create mode 100644 apps/app-frontend/src/components/ui/TitleBarAccountSwitcher.vue
diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
index 0ffddff..5cbe7d8 100644
--- a/apps/app-frontend/src/App.vue
+++ b/apps/app-frontend/src/App.vue
@@ -79,6 +79,7 @@ import { computed, nextTick, onMounted, onUnmounted, provide, ref, watch } from
import { RouterView, useRoute, useRouter } from 'vue-router'
import AccountsCard from '@/components/ui/AccountsCard.vue'
+import TitleBarAccountSwitcher from '@/components/ui/TitleBarAccountSwitcher.vue'
import AppActionBar from '@/components/ui/AppActionBar.vue'
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
import ErrorModal from '@/components/ui/ErrorModal.vue'
@@ -2509,6 +2510,13 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<AppActionBar />
</Suspense>
</div>
+ <!--
+ The account lives in the right sidebar. While that is folded away, it
+ is here instead, next to the window buttons, so it can still be seen
+ and switched.
+ -->
+ <!-- Flush against the window buttons, so no draggable gap is left between them. -->
+ <TitleBarAccountSwitcher v-if="!sidebarVisible" :card="accounts" />
<WindowControls />
</section>
</div>
diff --git a/apps/app-frontend/src/components/ui/AccountsCard.vue b/apps/app-frontend/src/components/ui/AccountsCard.vue
index 224d776..632483e 100644
--- a/apps/app-frontend/src/components/ui/AccountsCard.vue
+++ b/apps/app-frontend/src/components/ui/AccountsCard.vue
@@ -226,18 +226,6 @@ function showElyAccountModal(event?: MouseEvent) {
elyAccountModal.value?.show(event)
}
-defineExpose({
- refreshValues,
- showOfflineAccountModal,
- showElyAccountModal,
- setEquippedSkin,
- setLoginDisabled,
- login,
- loginDisabled,
-})
-
-await refreshValues()
-
const selectedAccount = computed(() =>
accounts.value.find((account) => account.profile.id === defaultUser.value),
)
@@ -256,6 +244,26 @@ const avatarUrl = computed(() => {
return 'https://launcher-files.modrinth.com/assets/steve_head.png'
})
+defineExpose({
+ refreshValues,
+ showOfflineAccountModal,
+ showElyAccountModal,
+ setEquippedSkin,
+ setLoginDisabled,
+ login,
+ loginDisabled,
+ // For the title bar's account switcher, which stands in for this card while
+ // the sidebar is folded away.
+ accounts,
+ selectedAccount,
+ avatarUrl,
+ getAccountAvatarUrl,
+ setAccount,
+ logout,
+})
+
+await refreshValues()
+
function getAccountAvatarUrl(account: MinecraftCredential) {
if (
account.profile.id === selectedAccount.value?.profile?.id &&
diff --git a/apps/app-frontend/src/components/ui/TitleBarAccountSwitcher.vue b/apps/app-frontend/src/components/ui/TitleBarAccountSwitcher.vue
new file mode 100644
index 0000000..70ab9e9
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/TitleBarAccountSwitcher.vue
@@ -0,0 +1,163 @@
+<!--
+ The Minecraft account, in the title bar while the right sidebar is folded away:
+ shown, switched, added and removed.
+
+ The sidebar's account card does the work: it stays mounted while the sidebar is
+ hidden, so the account shown here, switching it and the sign-in dialogs are that
+ card's own, and a dialog opened from here outlives this menu closing.
+-->
+<template>
+ <TeleportPopoutMenu
+ v-if="card"
+ type="quiet"
+ :label="formatMessage(messages.switchAccount)"
+ :tooltip="formatMessage(messages.switchAccount)"
+ placement="bottom-end"
+ >
+ <template #trigger>
+ <Avatar size="24px" :src="card.avatarUrl" />
+ <span class="max-w-40 truncate">
+ {{ card.selectedAccount?.profile.name ?? formatMessage(messages.selectAccount) }}
+ </span>
+ <DropdownIcon class="size-4 text-secondary" />
+ </template>
+ <template #panel="{ close }">
+ <div class="flex w-64 flex-col gap-1 p-2">
+ <div
+ v-for="account in card.accounts"
+ :key="account.profile.id"
+ class="flex min-w-0 items-center gap-1"
+ >
+ <button
+ class="button-base flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-lg border-0 bg-transparent p-2 text-left"
+ @click="choose(account, close)"
+ >
+ <RadioButtonCheckedIcon v-if="isSelected(account)" class="size-5 shrink-0 text-brand" />
+ <RadioButtonIcon v-else class="size-5 shrink-0 text-secondary" />
+ <Avatar :src="card.getAccountAvatarUrl(account)" size="24px" />
+ <span
+ class="min-w-0 truncate"
+ :class="isSelected(account) ? 'font-semibold text-contrast' : 'text-primary'"
+ >
+ {{ account.profile.name }}
+ </span>
+ </button>
+ <!-- The menu stays open, so what is left is on show. -->
+ <IconButton
+ v-tooltip="formatMessage(messages.removeAccount)"
+ type="quiet"
+ color="red"
+ size="sm"
+ :label="formatMessage(messages.removeAccount)"
+ @click="card.logout(account.profile.id)"
+ >
+ <TrashIcon />
+ </IconButton>
+ </div>
+ <div v-if="card.accounts.length" class="my-1 h-px bg-surface-5" />
+ <Button
+ type="quiet"
+ class="!justify-start"
+ :disabled="card.loginDisabled"
+ @click="(event: MouseEvent) => add(close, () => card?.login(event))"
+ >
+ <PlusIcon />
+ {{ formatMessage(messages.addAccount) }}
+ </Button>
+ <Button
+ type="quiet"
+ class="!justify-start"
+ @click="(event: MouseEvent) => add(close, () => card?.showElyAccountModal(event))"
+ >
+ <KeyIcon />
+ {{ formatMessage(messages.addElyAccount) }}
+ </Button>
+ <Button
+ type="quiet"
+ class="!justify-start"
+ @click="(event: MouseEvent) => add(close, () => card?.showOfflineAccountModal(event))"
+ >
+ <UserIcon />
+ {{ formatMessage(messages.addOfflineAccount) }}
+ </Button>
+ </div>
+ </template>
+ </TeleportPopoutMenu>
+</template>
+
+<script setup lang="ts">
+import {
+ DropdownIcon,
+ KeyIcon,
+ PlusIcon,
+ RadioButtonCheckedIcon,
+ RadioButtonIcon,
+ TrashIcon,
+ UserIcon,
+} from '@modrinth/assets'
+import {
+ Avatar,
+ Button,
+ defineMessages,
+ IconButton,
+ TeleportPopoutMenu,
+ useVIntl,
+} from '@modrinth/ui'
+import type { PropType } from 'vue'
+
+import type AccountsCard from '@/components/ui/AccountsCard.vue'
+
+type Card = InstanceType<typeof AccountsCard>
+type Account = Card['accounts'][number]
+
+const props = defineProps({
+ card: {
+ type: Object as PropType<Card | null>,
+ default: null,
+ },
+})
+
+const { formatMessage } = useVIntl()
+
+const messages = defineMessages({
+ switchAccount: {
+ id: 'minecraft-account.switch',
+ defaultMessage: 'Switch account',
+ },
+ selectAccount: {
+ id: 'minecraft-account.select-account',
+ defaultMessage: 'Select account',
+ },
+ addAccount: {
+ id: 'minecraft-account.add-microsoft-account',
+ defaultMessage: 'Add Microsoft account',
+ },
+ addElyAccount: {
+ id: 'minecraft-account.add-ely-account',
+ defaultMessage: 'Add Ely.by account',
+ },
+ addOfflineAccount: {
+ id: 'minecraft-account.add-offline-account',
+ defaultMessage: 'Add offline account',
+ },
+ removeAccount: {
+ id: 'minecraft-account.remove-account',
+ defaultMessage: 'Remove account',
+ },
+})
+
+function isSelected(account: Account) {
+ return props.card?.selectedAccount?.profile.id === account.profile.id
+}
+
+async function choose(account: Account, close: () => void) {
+ close()
+ if (!isSelected(account)) await props.card?.setAccount(account)
+}
+
+/** Closes the menu first: the dialog belongs to the sidebar's card, not to it. */
+function add(close: () => void, open: () => void) {
+ close()
+ open()
+}
+</script>
+7 -1
View File
@@ -76,7 +76,9 @@ done < <(grep -A1 '\.plugin($' "$WORKTREE/apps/app/build.rs" |
log "Microsoft sign-in"
check "the browser flow is registered" \
contains "$WORKTREE/apps/app/src/api/auth.rs" 'login_browser_begin,'
contains "$WORKTREE/apps/app/src/api/auth.rs" 'login_device_begin,'
check "the browser sign-in comes back without pasting" \
contains "$WORKTREE/apps/app-frontend/src/components/ui/MicrosoftLoginModal.vue" 'login_device_poll('
check "the sign-in button opens it" \
contains "$WORKTREE/apps/app-frontend/src/components/ui/AccountsCard.vue" 'microsoftLoginModal.value?.show'
@@ -135,6 +137,10 @@ check "the news section can be collapsed" \
contains "$WORKTREE/apps/app-frontend/src/App.vue" 'setNewsCollapsed'
check "the right sidebar has a fold button" \
contains "$WORKTREE/apps/app-frontend/src/App.vue" 'setSidebarCollapsed(sidebarToggled)'
check "folding the sidebar is explained once" \
contains "$WORKTREE/apps/app-frontend/src/App.vue" 'sidebarRememberedModal.value?.show()'
check "the account stays reachable with the sidebar folded" \
contains "$WORKTREE/apps/app-frontend/src/App.vue" '<TitleBarAccountSwitcher'
# tauri.linux.conf.json replaces the whole window list, so an upstream change to
# the main window would otherwise silently not reach Linux.