Upstream now depends on the md5 crate, so the offline account UUID uses it instead of md-5.
624 lines
22 KiB
Diff
624 lines
22 KiB
Diff
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
|
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
|
|
Date: Mon, 14 Sep 2026 10:27:34 +0200
|
|
Subject: [PATCH] Add offline accounts
|
|
|
|
Adds a second way to add a Minecraft account that never talks to
|
|
Microsoft or Mojang, for playing singleplayer worlds and servers running
|
|
in offline mode.
|
|
|
|
An offline account is an ordinary row in `minecraft_users`, marked by a
|
|
sentinel in the refresh token column, so no database migration is
|
|
needed. `Credentials::refresh` and the online profile lookup both return
|
|
early for such an account, which keeps every existing code path -
|
|
launching, account switching, serialisation to the frontend - working
|
|
without further changes.
|
|
|
|
The player UUID is derived the way Minecraft itself derives it, as an
|
|
MD5 name UUID over `OfflinePlayer:<name>`. That is what vanilla servers
|
|
in offline mode and other launchers use, so worlds keep the same player
|
|
data when they are opened elsewhere.
|
|
|
|
Usernames are validated the way Mojang validates them: 3 to 16
|
|
characters of letters, numbers and underscores.
|
|
|
|
Three places had to be opened up for any of this to be reachable, all of
|
|
which offered Microsoft and nothing else:
|
|
|
|
* The account card was hidden until a Microsoft account had been added,
|
|
which left offline sign-in unreachable for exactly the people who want
|
|
it.
|
|
* The "Minecraft required" modal - what you hit on pressing Play with no
|
|
account - now lists the alternatives too. It is no longer only about
|
|
something being required, so it is titled "Sign in to Minecraft".
|
|
* "Sign in to Minecraft" in the getting started checklist went straight
|
|
to Microsoft. It opens that same modal now, so every way in offers the
|
|
same choice.
|
|
---
|
|
Cargo.lock | 1 +
|
|
apps/app-frontend/src/App.vue | 12 +-
|
|
.../src/components/ui/AccountsCard.vue | 30 ++++
|
|
.../src/components/ui/OfflineAccountModal.vue | 136 ++++++++++++++++++
|
|
.../MinecraftRequiredModal.vue | 30 +++-
|
|
apps/app-frontend/src/helpers/auth.js | 13 ++
|
|
apps/app/build.rs | 1 +
|
|
apps/app/src/api/auth.rs | 7 +
|
|
packages/app-lib/Cargo.toml | 1 +
|
|
packages/app-lib/src/api/minecraft_auth.rs | 39 +++++
|
|
packages/app-lib/src/state/minecraft_auth.rs | 66 +++++++++
|
|
11 files changed, 328 insertions(+), 8 deletions(-)
|
|
create mode 100644 apps/app-frontend/src/components/ui/OfflineAccountModal.vue
|
|
|
|
diff --git a/Cargo.lock b/Cargo.lock
|
|
index 28231e3..f94d0fe 100644
|
|
--- a/Cargo.lock
|
|
+++ b/Cargo.lock
|
|
@@ -12361,6 +12361,7 @@ dependencies = [
|
|
"indicatif",
|
|
"itertools 0.14.0",
|
|
"json5",
|
|
+ "md5",
|
|
"modrinth-content-management",
|
|
"notify",
|
|
"notify-debouncer-mini",
|
|
diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
|
|
index 447f636..d750a5f 100644
|
|
--- a/apps/app-frontend/src/App.vue
|
|
+++ b/apps/app-frontend/src/App.vue
|
|
@@ -2467,15 +2467,17 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
|
>
|
|
<OnboardingChecklist
|
|
@create-instance="installationModal?.show()"
|
|
- @login-minecraft="accounts?.login()"
|
|
+ @login-minecraft="minecraftRequiredModal?.show()"
|
|
@login-modrinth="signIn"
|
|
/>
|
|
<div id="sidebar-teleport-target" class="sidebar-teleport-content"></div>
|
|
<div class="sidebar-default-content" :class="{ 'sidebar-enabled': sidebarVisible }">
|
|
- <div
|
|
- v-show="hasLoggedIntoMinecraft"
|
|
- class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"
|
|
- >
|
|
+ <!--
|
|
+ Upstream hides this until a Microsoft account has been added, which
|
|
+ would leave the offline sign-in below it unreachable for exactly the
|
|
+ people who want it.
|
|
+ -->
|
|
+ <div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
|
|
<h3 class="text-base text-primary font-medium m-0">
|
|
{{ formatMessage(messages.playingAs) }}
|
|
</h3>
|
|
diff --git a/apps/app-frontend/src/components/ui/AccountsCard.vue b/apps/app-frontend/src/components/ui/AccountsCard.vue
|
|
index 70cc46e..ebc92c1 100644
|
|
--- a/apps/app-frontend/src/components/ui/AccountsCard.vue
|
|
+++ b/apps/app-frontend/src/components/ui/AccountsCard.vue
|
|
@@ -9,6 +9,10 @@
|
|
<SpinnerIcon v-else class="animate-spin" />
|
|
{{ formatMessage(messages.signInToMinecraft) }}
|
|
</Button>
|
|
+ <Button @click="offlineAccountModal?.show($event)">
|
|
+ <UserIcon />
|
|
+ {{ formatMessage(messages.addOfflineAccount) }}
|
|
+ </Button>
|
|
</div>
|
|
<Accordion
|
|
v-else
|
|
@@ -80,9 +84,17 @@
|
|
<PlusIcon />
|
|
{{ formatMessage(messages.addAccount) }}
|
|
</Button>
|
|
+ <Button
|
|
+ class="w-full !bg-button-bg !text-primary ![box-shadow:var(--shadow-button)]"
|
|
+ @click="offlineAccountModal?.show($event)"
|
|
+ >
|
|
+ <UserIcon />
|
|
+ {{ formatMessage(messages.addOfflineAccount) }}
|
|
+ </Button>
|
|
</div>
|
|
</div>
|
|
</Accordion>
|
|
+ <OfflineAccountModal ref="offlineAccountModal" @created="offlineAccountCreated" />
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
@@ -93,6 +105,7 @@ import {
|
|
RadioButtonIcon,
|
|
SpinnerIcon,
|
|
TrashIcon,
|
|
+ UserIcon,
|
|
} from '@modrinth/assets'
|
|
import {
|
|
Accordion,
|
|
@@ -106,6 +119,7 @@ import {
|
|
import type { Ref } from 'vue'
|
|
import { computed, onUnmounted, ref } from 'vue'
|
|
|
|
+import OfflineAccountModal from '@/components/ui/OfflineAccountModal.vue'
|
|
import { useAppEvent } from '@/composables/use-app-event'
|
|
import { handleSevereError } from '@/composables/use-error.js'
|
|
import { trackEvent } from '@/helpers/analytics'
|
|
@@ -135,6 +149,7 @@ type MinecraftCredential = {
|
|
}
|
|
|
|
const accounts: Ref<MinecraftCredential[]> = ref([])
|
|
+const offlineAccountModal = ref<InstanceType<typeof OfflineAccountModal>>()
|
|
const loginDisabled = ref(false)
|
|
const defaultUser = ref<string | undefined>()
|
|
const equippedSkin = ref<Skin | null>(null)
|
|
@@ -187,8 +202,13 @@ function setLoginDisabled(value: boolean) {
|
|
loginDisabled.value = value
|
|
}
|
|
|
|
+function showOfflineAccountModal(event?: MouseEvent) {
|
|
+ offlineAccountModal.value?.show(event)
|
|
+}
|
|
+
|
|
defineExpose({
|
|
refreshValues,
|
|
+ showOfflineAccountModal,
|
|
setEquippedSkin,
|
|
setLoginDisabled,
|
|
login,
|
|
@@ -247,6 +267,12 @@ async function login() {
|
|
loginDisabled.value = false
|
|
}
|
|
|
|
+async function offlineAccountCreated() {
|
|
+ // `login_offline` already marks the new account as the active one.
|
|
+ await refreshValues()
|
|
+ emit('change')
|
|
+}
|
|
+
|
|
async function logout(id: string) {
|
|
await remove_user(id).catch(handleError)
|
|
await refreshValues()
|
|
@@ -273,6 +299,10 @@ const messages = defineMessages({
|
|
id: 'minecraft-account.add-account',
|
|
defaultMessage: 'Add account',
|
|
},
|
|
+ addOfflineAccount: {
|
|
+ id: 'minecraft-account.add-offline-account',
|
|
+ defaultMessage: 'Add offline account',
|
|
+ },
|
|
removeAccount: {
|
|
id: 'minecraft-account.remove-account',
|
|
defaultMessage: 'Remove account',
|
|
diff --git a/apps/app-frontend/src/components/ui/OfflineAccountModal.vue b/apps/app-frontend/src/components/ui/OfflineAccountModal.vue
|
|
new file mode 100644
|
|
index 0000000..8300e28
|
|
--- /dev/null
|
|
+++ b/apps/app-frontend/src/components/ui/OfflineAccountModal.vue
|
|
@@ -0,0 +1,136 @@
|
|
+<template>
|
|
+ <NewModal ref="modal" :header="formatMessage(messages.header)" max-width="480px" width="100%">
|
|
+ <div class="flex flex-col gap-4">
|
|
+ <p class="m-0 leading-tight text-secondary">
|
|
+ {{ formatMessage(messages.description) }}
|
|
+ </p>
|
|
+
|
|
+ <form class="flex flex-col gap-2" @submit.prevent="submit">
|
|
+ <label class="font-semibold text-contrast" for="offline-account-username">
|
|
+ {{ formatMessage(messages.usernameLabel) }}
|
|
+ </label>
|
|
+ <Input
|
|
+ id="offline-account-username"
|
|
+ ref="usernameInput"
|
|
+ v-model="username"
|
|
+ :icon="UserIcon"
|
|
+ :placeholder="formatMessage(messages.usernamePlaceholder)"
|
|
+ :error="!!error"
|
|
+ :maxlength="16"
|
|
+ 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>
|
|
+ </div>
|
|
+
|
|
+ <template #actions>
|
|
+ <div class="flex justify-end gap-2">
|
|
+ <Button native-type="button" @click="modal?.hide()">
|
|
+ <XIcon aria-hidden="true" />
|
|
+ {{ formatMessage(commonMessages.cancelButton) }}
|
|
+ </Button>
|
|
+ <Button
|
|
+ type="colored"
|
|
+ color="brand"
|
|
+ native-type="button"
|
|
+ :disabled="submitting || !username.trim()"
|
|
+ @click="submit"
|
|
+ >
|
|
+ <SpinnerIcon v-if="submitting" aria-hidden="true" class="animate-spin" />
|
|
+ <PlusIcon v-else aria-hidden="true" />
|
|
+ {{ formatMessage(messages.addButton) }}
|
|
+ </Button>
|
|
+ </div>
|
|
+ </template>
|
|
+ </NewModal>
|
|
+</template>
|
|
+
|
|
+<script setup lang="ts">
|
|
+import { PlusIcon, SpinnerIcon, UserIcon, XIcon } from '@modrinth/assets'
|
|
+import {
|
|
+ Button,
|
|
+ commonMessages,
|
|
+ defineMessages,
|
|
+ Input,
|
|
+ NewModal,
|
|
+ useVIntl,
|
|
+} from '@modrinth/ui'
|
|
+import { nextTick, ref } from 'vue'
|
|
+
|
|
+import { login_offline } from '@/helpers/auth'
|
|
+
|
|
+const { formatMessage } = useVIntl()
|
|
+
|
|
+const emit = defineEmits<{
|
|
+ created: [account: unknown]
|
|
+}>()
|
|
+
|
|
+const modal = ref<InstanceType<typeof NewModal>>()
|
|
+const username = ref('')
|
|
+const error = ref('')
|
|
+const submitting = ref(false)
|
|
+
|
|
+function show(event?: MouseEvent) {
|
|
+ username.value = ''
|
|
+ error.value = ''
|
|
+ submitting.value = false
|
|
+ modal.value?.show(event)
|
|
+ void nextTick(() => {
|
|
+ document.getElementById('offline-account-username')?.focus()
|
|
+ })
|
|
+}
|
|
+
|
|
+async function submit() {
|
|
+ if (submitting.value) return
|
|
+
|
|
+ const name = username.value.trim()
|
|
+ if (!name) return
|
|
+
|
|
+ submitting.value = true
|
|
+ error.value = ''
|
|
+
|
|
+ try {
|
|
+ const account = await login_offline(name)
|
|
+ modal.value?.hide()
|
|
+ emit('created', account)
|
|
+ } catch (e) {
|
|
+ error.value =
|
|
+ typeof e === 'string' ? e : ((e as Error)?.message ?? formatMessage(messages.genericError))
|
|
+ } finally {
|
|
+ submitting.value = false
|
|
+ }
|
|
+}
|
|
+
|
|
+defineExpose({ show })
|
|
+
|
|
+const messages = defineMessages({
|
|
+ header: {
|
|
+ id: 'app.offline-account.header',
|
|
+ defaultMessage: 'Add an offline account',
|
|
+ },
|
|
+ description: {
|
|
+ id: 'app.offline-account.description',
|
|
+ defaultMessage:
|
|
+ 'An offline account never contacts Microsoft or Mojang. You can play singleplayer worlds and join servers running in offline mode; servers in online mode will reject it.',
|
|
+ },
|
|
+ usernameLabel: {
|
|
+ id: 'app.offline-account.username-label',
|
|
+ defaultMessage: 'Username',
|
|
+ },
|
|
+ usernamePlaceholder: {
|
|
+ id: 'app.offline-account.username-placeholder',
|
|
+ defaultMessage: '3 to 16 letters, numbers or underscores',
|
|
+ },
|
|
+ addButton: {
|
|
+ id: 'app.offline-account.add-button',
|
|
+ defaultMessage: 'Add account',
|
|
+ },
|
|
+ genericError: {
|
|
+ id: 'app.offline-account.generic-error',
|
|
+ defaultMessage: 'Could not add the offline account.',
|
|
+ },
|
|
+})
|
|
+</script>
|
|
diff --git a/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue b/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
|
|
index 73293f5..8e6b275 100644
|
|
--- a/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
|
|
+++ b/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
|
|
@@ -43,6 +43,16 @@
|
|
{{ formatMessage(messages.signIn) }}
|
|
</Button>
|
|
</div>
|
|
+ <div class="flex flex-col gap-2 border-0 border-t border-solid border-surface-4 pt-5">
|
|
+ <p class="m-0 text-sm leading-tight text-secondary">
|
|
+ {{ formatMessage(messages.offlineHint) }}
|
|
+ </p>
|
|
+ <Button @click="addOfflineAccount">
|
|
+ <UserIcon />
|
|
+ {{ formatMessage(messages.addOfflineAccount) }}
|
|
+ </Button>
|
|
+ </div>
|
|
+
|
|
<p class="m-0 text-center text-sm text-secondary">
|
|
{{ formatMessage(messages.dontHaveAccount) }}
|
|
<a
|
|
@@ -57,7 +67,7 @@
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
-import { MessagesSquareIcon, SpinnerIcon } from '@modrinth/assets'
|
|
+import { MessagesSquareIcon, SpinnerIcon, UserIcon } from '@modrinth/assets'
|
|
import { Button, ButtonLink, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
|
|
import { inject, type Ref, ref } from 'vue'
|
|
|
|
@@ -73,7 +83,7 @@ const accountsCard = inject('accountsCard') as Ref<InstanceType<typeof AccountsC
|
|
const messages = defineMessages({
|
|
header: {
|
|
id: 'minecraft-required.header',
|
|
- defaultMessage: 'Minecraft required',
|
|
+ defaultMessage: 'Sign in to Minecraft',
|
|
},
|
|
descriptionHeader: {
|
|
id: 'minecraft-required.description-header',
|
|
@@ -82,7 +92,7 @@ const messages = defineMessages({
|
|
description: {
|
|
id: 'minecraft-required.description',
|
|
defaultMessage:
|
|
- 'You need a Microsoft account that owns Minecraft before you can launch and play.',
|
|
+ 'A Microsoft account that owns Minecraft is what lets you play online and keep your skin.',
|
|
},
|
|
getSupport: {
|
|
id: 'minecraft-required.get-support',
|
|
@@ -100,6 +110,15 @@ const messages = defineMessages({
|
|
id: 'minecraft-required.get-minecraft',
|
|
defaultMessage: 'Get Minecraft',
|
|
},
|
|
+ offlineHint: {
|
|
+ id: 'minecraft-required.offline-hint',
|
|
+ defaultMessage:
|
|
+ 'Or play singleplayer and offline-mode servers without an account of any kind.',
|
|
+ },
|
|
+ addOfflineAccount: {
|
|
+ id: 'minecraft-required.add-offline-account',
|
|
+ defaultMessage: 'Add offline account',
|
|
+ },
|
|
})
|
|
|
|
const modal = ref<InstanceType<typeof NewModal>>()
|
|
@@ -127,6 +146,11 @@ async function signIn() {
|
|
}
|
|
}
|
|
|
|
+function addOfflineAccount(event: MouseEvent) {
|
|
+ modal.value?.hide()
|
|
+ accountsCard.value?.showOfflineAccountModal(event)
|
|
+}
|
|
+
|
|
defineExpose({
|
|
show,
|
|
})
|
|
diff --git a/apps/app-frontend/src/helpers/auth.js b/apps/app-frontend/src/helpers/auth.js
|
|
index 94bd13e..cb7319a 100644
|
|
--- a/apps/app-frontend/src/helpers/auth.js
|
|
+++ b/apps/app-frontend/src/helpers/auth.js
|
|
@@ -33,6 +33,19 @@ export async function login() {
|
|
return await invoke('plugin:auth|login')
|
|
}
|
|
|
|
+/**
|
|
+ * Adds an offline account with the given username and makes it the active one.
|
|
+ *
|
|
+ * Offline accounts never contact Microsoft or Mojang. They can play
|
|
+ * singleplayer worlds and join servers running in offline mode.
|
|
+ *
|
|
+ * @param {string} username
|
|
+ * @returns {Promise<Credential>}
|
|
+ */
|
|
+export async function login_offline(username) {
|
|
+ return await invoke('plugin:auth|login_offline', { username })
|
|
+}
|
|
+
|
|
/**
|
|
* Retrieves the default user
|
|
* @return {Promise<UUID | undefined>}
|
|
diff --git a/apps/app/build.rs b/apps/app/build.rs
|
|
index 926cab4..68a02aa 100644
|
|
--- a/apps/app/build.rs
|
|
+++ b/apps/app/build.rs
|
|
@@ -14,6 +14,7 @@ fn main() {
|
|
.commands(&[
|
|
"check_reachable",
|
|
"login",
|
|
+ "login_offline",
|
|
"remove_user",
|
|
"get_default_user",
|
|
"set_default_user",
|
|
diff --git a/apps/app/src/api/auth.rs b/apps/app/src/api/auth.rs
|
|
index 8227d94..f4eded6 100644
|
|
--- a/apps/app/src/api/auth.rs
|
|
+++ b/apps/app/src/api/auth.rs
|
|
@@ -9,6 +9,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
|
.invoke_handler(tauri::generate_handler![
|
|
check_reachable,
|
|
login,
|
|
+ login_offline,
|
|
remove_user,
|
|
get_default_user,
|
|
set_default_user,
|
|
@@ -86,6 +87,12 @@ pub async fn login<R: Runtime>(
|
|
Ok(None)
|
|
}
|
|
|
|
+/// Adds an offline account with the given username and makes it active.
|
|
+#[tauri::command]
|
|
+pub async fn login_offline(username: String) -> Result<Credentials> {
|
|
+ Ok(minecraft_auth::login_offline(&username).await?)
|
|
+}
|
|
+
|
|
#[tauri::command]
|
|
pub async fn remove_user(user: uuid::Uuid) -> Result<()> {
|
|
Ok(minecraft_auth::remove_user(user).await?)
|
|
diff --git a/packages/app-lib/Cargo.toml b/packages/app-lib/Cargo.toml
|
|
index 9bb14ce..58f85e3 100644
|
|
--- a/packages/app-lib/Cargo.toml
|
|
+++ b/packages/app-lib/Cargo.toml
|
|
@@ -52,6 +52,7 @@ image = { workspace = true, features = ["gif", "jpeg", "png", "webp"] }
|
|
indicatif = { workspace = true, optional = true }
|
|
itertools = { workspace = true }
|
|
json5 = { workspace = true }
|
|
+md5 = { workspace = true }
|
|
modrinth-content-management = { workspace = true }
|
|
notify = { workspace = true }
|
|
notify-debouncer-mini = { workspace = true }
|
|
diff --git a/packages/app-lib/src/api/minecraft_auth.rs b/packages/app-lib/src/api/minecraft_auth.rs
|
|
index e7195c6..a7fac4a 100644
|
|
--- a/packages/app-lib/src/api/minecraft_auth.rs
|
|
+++ b/packages/app-lib/src/api/minecraft_auth.rs
|
|
@@ -47,6 +47,45 @@ pub async fn finish_login(
|
|
Ok(credentials)
|
|
}
|
|
|
|
+/// Creates an offline account for `username`, or reuses the existing one, and
|
|
+/// makes it the active account.
|
|
+///
|
|
+/// Offline accounts never contact Microsoft or Mojang. They are enough to play
|
|
+/// singleplayer worlds and to join servers running in offline mode, and they
|
|
+/// are refused by servers in online mode, exactly like offline accounts in
|
|
+/// other launchers.
|
|
+#[tracing::instrument]
|
|
+pub async fn login_offline(username: &str) -> crate::Result<Credentials> {
|
|
+ let username = username.trim();
|
|
+
|
|
+ if !(3..=16).contains(&username.len())
|
|
+ || !username
|
|
+ .bytes()
|
|
+ .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
|
|
+ {
|
|
+ return Err(crate::ErrorKind::InputError(
|
|
+ "An offline username must be 3 to 16 characters long and may only \
|
|
+ contain letters, numbers and underscores"
|
|
+ .to_string(),
|
|
+ )
|
|
+ .into());
|
|
+ }
|
|
+
|
|
+ let state = State::get().await?;
|
|
+ let credentials = Credentials::offline(username);
|
|
+ credentials.upsert(&state.pool).await?;
|
|
+
|
|
+ if let Err(error) =
|
|
+ crate::onboarding_checklist::mark_logged_into_minecraft().await
|
|
+ {
|
|
+ tracing::warn!(
|
|
+ "Failed to mark Minecraft login in onboarding checklist: {error}"
|
|
+ );
|
|
+ }
|
|
+
|
|
+ Ok(credentials)
|
|
+}
|
|
+
|
|
#[tracing::instrument]
|
|
pub async fn get_default_user() -> crate::Result<Option<uuid::Uuid>> {
|
|
let state = State::get().await?;
|
|
diff --git a/packages/app-lib/src/state/minecraft_auth.rs b/packages/app-lib/src/state/minecraft_auth.rs
|
|
index b835ad4..14455ec 100644
|
|
--- a/packages/app-lib/src/state/minecraft_auth.rs
|
|
+++ b/packages/app-lib/src/state/minecraft_auth.rs
|
|
@@ -212,6 +212,33 @@ pub struct Credentials {
|
|
pub active: bool,
|
|
}
|
|
|
|
+/// Access token handed to Minecraft for offline accounts.
|
|
+///
|
|
+/// The game only uses this token to talk to Mojang's session server, which an
|
|
+/// offline account never does, so the value just has to be non-empty. `0` is
|
|
+/// what launchers have traditionally used for offline play.
|
|
+const OFFLINE_ACCESS_TOKEN: &str = "0";
|
|
+
|
|
+/// Marker stored in an offline account's refresh token column.
|
|
+///
|
|
+/// Microsoft refresh tokens are opaque base64, so this value cannot collide
|
|
+/// with a real one, and reusing an existing column means offline accounts need
|
|
+/// no database migration.
|
|
+const OFFLINE_REFRESH_TOKEN: &str = "modrinth-enhanced:offline-account";
|
|
+
|
|
+/// Computes the player UUID Minecraft itself derives for an offline player.
|
|
+///
|
|
+/// This mirrors Java's `UUID.nameUUIDFromBytes("OfflinePlayer:<name>")`, which
|
|
+/// is what vanilla servers in offline mode and other launchers use. Matching it
|
|
+/// means a world played here keeps the same player data when it is opened from
|
|
+/// somewhere else.
|
|
+pub fn offline_uuid(username: &str) -> Uuid {
|
|
+ let mut bytes = md5::compute(format!("OfflinePlayer:{username}")).0;
|
|
+ bytes[6] = (bytes[6] & 0x0f) | 0x30; // Version 3
|
|
+ bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant
|
|
+ Uuid::from_bytes(bytes)
|
|
+}
|
|
+
|
|
/// An entry in the player profile cache, keyed by player UUID.
|
|
pub(super) enum ProfileCacheEntry {
|
|
/// A cached profile that is valid, even though it may be stale.
|
|
@@ -265,12 +292,45 @@ impl OnlineProfileCacheIntent {
|
|
}
|
|
|
|
impl Credentials {
|
|
+ /// Builds credentials for an offline account with the given username.
|
|
+ ///
|
|
+ /// Offline accounts hold no Microsoft tokens and have no Mojang profile
|
|
+ /// behind them; they exist so the launcher can start the game for
|
|
+ /// singleplayer worlds and offline-mode servers without signing in.
|
|
+ pub fn offline(username: &str) -> Self {
|
|
+ Self {
|
|
+ offline_profile: MinecraftProfile {
|
|
+ id: offline_uuid(username),
|
|
+ name: username.to_owned(),
|
|
+ ..MinecraftProfile::default()
|
|
+ },
|
|
+ access_token: OFFLINE_ACCESS_TOKEN.to_owned(),
|
|
+ refresh_token: OFFLINE_REFRESH_TOKEN.to_owned(),
|
|
+ // There is nothing to expire. `refresh` returns early for offline
|
|
+ // accounts, and a far future date keeps every other expiry check
|
|
+ // from doing anything surprising.
|
|
+ expires: Utc::now() + Duration::days(365 * 100),
|
|
+ active: true,
|
|
+ }
|
|
+ }
|
|
+
|
|
+ /// Whether these credentials belong to an offline account.
|
|
+ pub fn is_offline(&self) -> bool {
|
|
+ self.refresh_token == OFFLINE_REFRESH_TOKEN
|
|
+ }
|
|
+
|
|
/// Refreshes the authentication tokens for this user if they are expired, or
|
|
/// very close to expiration.
|
|
async fn refresh(
|
|
&mut self,
|
|
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
|
) -> crate::Result<()> {
|
|
+ // Offline accounts have no tokens to refresh and nothing to ask
|
|
+ // Microsoft about.
|
|
+ if self.is_offline() {
|
|
+ return Ok(());
|
|
+ }
|
|
+
|
|
// Use a margin of 5 minutes to give e.g. Minecraft and potentially
|
|
// other operations that depend on a fresh token 5 minutes to complete
|
|
// from now, and deal with some classes of clock skew
|
|
@@ -351,6 +411,12 @@ impl Credentials {
|
|
&self,
|
|
cache_intent: OnlineProfileCacheIntent,
|
|
) -> Option<Arc<MinecraftProfile>> {
|
|
+ // Offline accounts have no Mojang profile, so skip the request that
|
|
+ // would only ever fail and fall back to the offline profile.
|
|
+ if self.is_offline() {
|
|
+ return None;
|
|
+ }
|
|
+
|
|
let max_age = cache_intent.max_age();
|
|
let stale_profile = {
|
|
let mut profile_cache = PROFILE_CACHE.lock().await;
|