Compare commits

..
Author SHA1 Message Date
Calum H.andGitHub a77e4af0ed Merge branch 'main' into test 2026-06-26 17:23:35 +01:00
Calum H.andGitHub 1067b02830 fix: logs using id not path (#6511)
* fix: logs using id not path

* fix: lint
2026-06-26 17:50:26 +02:00
Calum H.andGitHub ebf4a6ef5f fix: bulk update updating linked content (#6517)
* fix: bulk update updating linked content

* fix: disabled content sometimes desyncing

* fix: bad filter

* fix: fmt
2026-06-26 17:50:15 +02:00
Calum H.andGitHub e4376c2b15 fix: clamp negative playtime (#6519)
* fix: clamp negative playtime

* fix: remove triggers use instant
2026-06-26 17:50:03 +02:00
Michael H. d019ac7fcf test: remove blacksmith 2026-06-26 17:45:37 +02:00
Michael H. 076aaac3eb test: remove mold 2026-06-26 17:40:16 +02:00
Michael H. 7ae71d04a6 test: remove sccache 2026-06-26 17:31:45 +02:00
Michael H. 5fe07de187 chore: bump mold and add mold to other jobs 2026-06-26 17:22:46 +02:00
Prospector 80d66d3c6e update ads.txt 2026-06-25 21:13:42 -07:00
Calum H.andGitHub 64f7fb811e feat: changelog 25th june (#6507) 2026-06-25 23:25:07 +02:00
734720e11e feat: instances v2 (#6431)
* feat: base of instances v2

* feat: use old profiles with compat layer

* prototype: instances v2

* fix: install_from using profile

* fix: skins migration fix

* fix: frontend still using profile path

* fix: add update proj multiselect guard

* fix: cargo fmt

* fix: content missing fields

* feat: break up app-lib/api/instance.rs

* fix: check_content_updates mismatch

* fix: updater modal cleanup w/new structure

* feat: better update all handling

* fix: remove preview_update_all

* fix: feedback on bulk update + lint

* fix: rem transitions

* fix: change to jsonb

* feat: app db backup after update

* fix: lint

* fix: sqlx prepare + use sqlx macros

* fix: lint

* fix: bugs

* feat: defuck the installing process up

* fix: bug of hell

* fix: shear

* fix: fmt

* fix: install progress spacing + change mc/content/overrides to bytes

* fix: lint

* fix: prepr

* fix: navtabs anim not working in app

* fix: worlds.vue improvements + browse page fixes

* feat: optimise queries + adapter fns

* fix: lint

* fix: lint

* feat: shared modrinth-content-management crate (#6469)

* feat: disable warnings setting

* feat: add instances shortcuts (#6329)

* Add modrinth://launch deep link to start a profile

Support external profile launching via modrinth://launch/{profile_path} for integrations such as Stream Deck.

* Change route to /launch/profile/{id} for future extensibility

* fix: ensure profile path is url decoded

* fix: URL-decode profile path from deep link

* fix: use urlencoding crate for URL decoding

* feat: implement app instance shortcuts

* feat: change windows shortcut creation to use windows api instead

* feat: implement creating a shortcut launching world/server

* format

* fmt

* fix multiline inline tables

* pnpm prepr

* feat: move create shortcut to last item

* refactor: split up shortcuts.rs for individual platforms

* refactor: turn profile launch url into url type

* use string literal and add safety comment

* pt2

* refactor: rename anything that's profile into instance

* update mac shortcut

---------

Co-authored-by: DJCheesusReal <134006619+DJCheesusReal@users.noreply.github.com>

---------

Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
Co-authored-by: DJCheesusReal <134006619+DJCheesusReal@users.noreply.github.com>
2026-06-25 21:19:29 +00:00
ef4044534f feat: update auth flow (#5790)
* Backend routes for choosing username in OAuth flow

* fix up oauth flow routes

* improve URL-related OAuth code

* Use user-provided callback addr instead of SELF_ADDR

* Revert "Use user-provided callback addr instead of SELF_ADDR"

This reverts commit 7ea0635d86.

* fix flow

* fix: backend response for create oauth account

* feat: new auth flow (#5840)

* update auth with new designs

* refactor: auth.js to auth.ts

* refactor: componentize auth pages

* fix: auth pages height

* feat: initial implementation of new sign-in oauth

* fix create account flow

* fix checkbox

* remove hard coded username

* implement create user validation endpoint and add more specific error responses

* feat: implement under 13 DOB guard and email/password validation route

* fix: TOCTOU issue

* refactor: pnpm prepr

* fix: make sure staging uses staging

* fix: hcaptcha styles

* fix: copy

* remove: auth/welcome page as its no longer used

* refactor: bring root page card styles into individual components and use tailwind

* fix: account settings modals to use new modal and fix lots of bad styles

* refactor: pnpm prepr

* feat: implement last signed in indicator

* fix: append number when generated name from email is taken

* refactor: pnpm prepr

* fix: last sign in badge color

* fix: qa issues

* refactor: pnpm prepr

* fix: hover effect on native date picker

* chore: temp staging undo

* Revert "chore: temp staging undo"

This reverts commit cad6bd4f92.

* feat: handle app create account

* fix: last signed in style

* fix: add initOnMounted for SSR race

* refactor: use typescript

* refactor: pnpm prepr

* refactor: use typescript for reset-password

* refactor: convert verify-email to use typescript

* refactor: convert authorize.vue to use typescript

* fix: authorize.vue error states

* feat: small style updates

* feat: implement date picker component

* feat: improve UX and styles for range select

* refactor: pnpm prepr

* fix: range select border styles

* feat: implement date picker component in create account

* feat: implement preserve date for date picker

* update rust toolchain

* increase recursion limit

* fix: date picker can be null

* fix: calculate age based on user's timezone

* fix: number input icons color

* fix: date picker icons

* feat: improve styles

* fix: add width on date

* fix: hover color bad on number input

* fix lints

* feat: add default date open view

* fmt

* fix: account.vue

* fix: remove default date to open 13 years ago

* fix: edit copy on info banner

* fix: cannot hover over project card tooltip items (#6071)

fix: cannot hover over project cards

* feat: improve add dependency flow (#6075)

* fix: shadow on nav

* feat: improve add dependency flow

* feat: update suggested dependency style

* feat: update dependency rows to use version number and update styles

* feat: implement combobox select searched text on focus

* feat: add Tabs.vue

* feat: update nav tabs to use tabs

* feat: improve project search dropdown

* fix: dependency search not clearing inbound query

* fix: combobox no options open state bug

* feat: improve dependency project and version search

* fix: open modrinth project links in the app (#6072)

* pin tanstack versions + set pnpm min age to 7 days

* squash commits

* fix: 2 factor auth enter code screen styles

* update copy

* update copy

* improve reset password

* feat: update sign in screen

* fix: unused import

* Merge branch 'main' into boris/dev-908-backend-changes

* Revert "Merge branch 'main' into boris/dev-908-backend-changes"

This reverts commit b9b03796e3.

* fix: add stroke

* feat: add passkey support (#6375)

* feat: add passkey backend

* feat: passkey frontend

* invalidate sessions on compromised passkey

* chore: run sqlx prepare

* fix: make passkey button use both collumns to prevent empty space

* fix: correctly verify max passkeys in finish route

* fix: use structs for response

* fix: add rp name default

* style: use web::Json

* fmt

* feat: improve manage passkeys UI

* fix copy

* pnpm prepr

---------

Co-authored-by: tdgao <mr.trumgao@gmail.com>
Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
Co-authored-by: Michael H. <michael@iptables.sh>
Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
Co-authored-by: Calum H. <calum@modrinth.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
Co-authored-by: DeDiamondPro <67508414+DeDiamondPro@users.noreply.github.com>
2026-06-25 20:36:19 +00:00
68 changed files with 1173 additions and 398 deletions
-22
View File
@@ -73,17 +73,6 @@ jobs:
docker:
runs-on: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
env:
SCCACHE_DIR: ${{ needs.skip-if-clean.outputs.internal == 'true' && '/mnt/sccache' || '' }}
SCCACHE_CACHE_SIZE: ${{ needs.skip-if-clean.outputs.internal == 'true' && '10G' || '' }}
SCCACHE_MULTILEVEL_CHAIN: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'disk,s3' || '' }}
SCCACHE_S3_KEY_PREFIX: ${{ needs.skip-if-clean.outputs.internal == 'true' && format('{0}/', github.repository) || '' }}
SCCACHE_BUCKET: ${{ secrets.SCCACHE_BUCKET }}
SCCACHE_REGION: ${{ secrets.SCCACHE_REGION }}
SCCACHE_ENDPOINT: ${{ secrets.SCCACHE_ENDPOINT }}
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
RUSTC_WRAPPER: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'sccache' || '' }}
needs: [skip-if-clean]
if: ${{ needs.skip-if-clean.outputs.skip != 'true' }}
steps:
@@ -105,17 +94,6 @@ jobs:
~/.cargo/bin
key: ${{ runner.os }}-${{ runner.arch }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Mount sccache disk cache
if: needs.skip-if-clean.outputs.internal == 'true'
uses: useblacksmith/stickydisk@13af8883542ca949a717e70fef89d15edbb29d88 # v1.2.0
with:
key: ${{ github.repository }}-daedalus-sccache
path: /mnt/sccache
- name: Setup sccache
if: needs.skip-if-clean.outputs.internal == 'true'
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
- name: Build daedalus_client
run: cargo build --release --package daedalus_client
-24
View File
@@ -76,16 +76,6 @@ jobs:
env:
SQLX_OFFLINE: 'true'
GIT_HASH: ${{ github.sha }}
SCCACHE_DIR: ${{ needs.skip-if-clean.outputs.internal == 'true' && '/mnt/sccache' || '' }}
SCCACHE_CACHE_SIZE: ${{ needs.skip-if-clean.outputs.internal == 'true' && '10G' || '' }}
SCCACHE_MULTILEVEL_CHAIN: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'disk,s3' || '' }}
SCCACHE_S3_KEY_PREFIX: ${{ needs.skip-if-clean.outputs.internal == 'true' && format('{0}/', github.repository) || '' }}
SCCACHE_BUCKET: ${{ secrets.SCCACHE_BUCKET }}
SCCACHE_REGION: ${{ secrets.SCCACHE_REGION }}
SCCACHE_ENDPOINT: ${{ secrets.SCCACHE_ENDPOINT }}
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
RUSTC_WRAPPER: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'sccache' || '' }}
steps:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -96,9 +86,6 @@ jobs:
rustflags: ''
cache: false
- name: Setup mold
uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 / Mold 2.41.0
- name: Install build dependencies
uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0
with:
@@ -114,17 +101,6 @@ jobs:
~/.cargo/bin
key: ${{ runner.os }}-${{ runner.arch }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Mount sccache disk cache
if: needs.skip-if-clean.outputs.internal == 'true'
uses: useblacksmith/stickydisk@13af8883542ca949a717e70fef89d15edbb29d88 # v1.2.0
with:
key: ${{ github.repository }}-labrinth-sccache
path: /mnt/sccache
- name: Setup sccache
if: needs.skip-if-clean.outputs.internal == 'true'
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
- name: Build labrinth
run: cargo build --profile release-labrinth --package labrinth
-13
View File
@@ -44,16 +44,6 @@ jobs:
name: Build
env:
VITE_STRIPE_PUBLISHABLE_KEY: pk_live_51JbFxJJygY5LJFfKLVVldb10HlLt24p421OWRsTOWc5sXYFOnFUXWieSc6HD3PHo25ktx8db1WcHr36XGFvZFVUz00V9ixrCs5
# SCCACHE_DIR: '/mnt/sccache'
# SCCACHE_CACHE_SIZE: '10G'
# SCCACHE_MULTILEVEL_CHAIN: 'disk,s3'
SCCACHE_S3_KEY_PREFIX: '${{ github.repository }}/'
SCCACHE_BUCKET: ${{ secrets.SCCACHE_BUCKET }}
SCCACHE_REGION: ${{ secrets.SCCACHE_REGION }}
SCCACHE_ENDPOINT: ${{ secrets.SCCACHE_ENDPOINT }}
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
RUSTC_WRAPPER: 'sccache'
strategy:
fail-fast: false
matrix:
@@ -84,9 +74,6 @@ jobs:
rustflags: ''
target: ${{ contains(matrix.platform, 'macos') && 'x86_64-apple-darwin' || '' }}
- name: Setup sccache
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
- name: Enable Corepack
run: corepack enable
+1 -39
View File
@@ -18,25 +18,11 @@ jobs:
runs-on: ubuntu-latest
outputs:
skip: ${{ steps.check.outputs.skip }}
internal: ${{ steps.check-internal.outputs.internal }}
if: ${{ always() }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check if workflow runs on an internal branch
id: check-internal
env:
EVENT_NAME: ${{ github.event_name }}
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
REPO: ${{ github.repository }}
run: |
if [ "$EVENT_NAME" != "pull_request" ] || [ "$HEAD_REPO" = "$REPO" ]; then
echo "internal=true" >> $GITHUB_OUTPUT
else
echo "internal=false" >> $GITHUB_OUTPUT
fi
- name: Merge Queue CI Check Skipper
id: merge-queue-ci-skipper
uses: ./.github/merge-queue-ci-skipper
@@ -62,7 +48,7 @@ jobs:
build:
name: Lint and Test
runs-on: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
runs-on: ubuntu-latest
needs: [skip-if-clean]
if: ${{ needs.skip-if-clean.outputs.skip != 'true' }}
@@ -76,19 +62,6 @@ jobs:
# since we don't want warnings to become errors
# while developing)
RUSTFLAGS: -Dwarnings
# sccache config (only populated for internal branches; secrets aren't
# available to forked PRs, and blacksmith stickydisk requires a
# blacksmith runner)
SCCACHE_DIR: ${{ needs.skip-if-clean.outputs.internal == 'true' && '/mnt/sccache' || '' }}
SCCACHE_CACHE_SIZE: ${{ needs.skip-if-clean.outputs.internal == 'true' && '10G' || '' }}
SCCACHE_MULTILEVEL_CHAIN: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'disk,s3' || '' }}
SCCACHE_S3_KEY_PREFIX: ${{ needs.skip-if-clean.outputs.internal == 'true' && format('{0}/', github.repository) || '' }}
SCCACHE_BUCKET: ${{ secrets.SCCACHE_BUCKET }}
SCCACHE_REGION: ${{ secrets.SCCACHE_REGION }}
SCCACHE_ENDPOINT: ${{ secrets.SCCACHE_ENDPOINT }}
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
RUSTC_WRAPPER: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'sccache' || '' }}
steps:
- name: Check out code
@@ -138,17 +111,6 @@ jobs:
~/.cargo/bin
key: ${{ runner.os }}-${{ runner.arch }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Mount sccache disk cache
if: needs.skip-if-clean.outputs.internal == 'true'
uses: useblacksmith/stickydisk@13af8883542ca949a717e70fef89d15edbb29d88 # v1.2.0
with:
key: ${{ github.repository }}-turbo-sccache
path: /mnt/sccache
- name: Setup sccache
if: needs.skip-if-clean.outputs.internal == 'true'
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
- name: Setup binstall
uses: cargo-bins/cargo-binstall@dc19f1e48450eefe5a29b8da6c6b00a87d730b37 # v1.18.1
Generated
+1
View File
@@ -10807,6 +10807,7 @@ dependencies = [
"tracing-error",
"tracing-subscriber",
"url",
"urlencoding",
"uuid 1.23.3",
"whoami",
"windows",
+15 -7
View File
@@ -95,7 +95,7 @@ import { check_reachable } from '@/helpers/auth.js'
import { get_user, get_version } from '@/helpers/cache.js'
import { command_listener, notification_listener, warning_listener } from '@/helpers/events.js'
import { install_create_modpack_instance, install_get_modpack_preview } from '@/helpers/install'
import { list } from '@/helpers/instance'
import { list, run } from '@/helpers/instance'
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
import { mergeUrlQuery, parseModrinthLink } from '@/helpers/project-links.ts'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
@@ -110,6 +110,7 @@ import {
isNetworkMetered,
setRestartAfterPendingUpdate,
} from '@/helpers/utils.js'
import { start_join_server, start_join_singleplayer_world } from '@/helpers/worlds.ts'
import i18n from '@/i18n.config'
import {
appUpdateState,
@@ -868,6 +869,14 @@ async function handleCommand(e) {
source: 'CreationModalFileDrop',
})
}
} else if (e.event === 'LaunchInstance') {
if (e.server) {
await start_join_server(e.id, e.server).catch(handleError)
} else if (e.singleplayer_world) {
await start_join_singleplayer_world(e.id, e.singleplayer_world).catch(handleError)
} else {
await run(e.id).catch(handleError)
}
} else if (e.event === 'InstallServer') {
await router.push(`/project/${e.id}`)
await playServerProject(e.id).catch(handleError)
@@ -1332,12 +1341,11 @@ async function processPendingSurveys() {
const creds = await getCreds().catch(handleError)
const userId = creds?.user_id
const instances = await list().catch(handleError)
const isActivePlayer =
instances.findIndex(
(instance) =>
isWithinLastTwoWeeks(instance.last_played) && !isWithinLastTwoWeeks(instance.created),
) >= 0
const instances = (await list().catch(handleError)) ?? []
const isActivePlayer = instances.some(
(instance) =>
isWithinLastTwoWeeks(instance.last_played) && !isWithinLastTwoWeeks(instance.created),
)
let surveys = []
try {
@@ -35,6 +35,7 @@ import {
import { get_loader_versions } from '@/helpers/metadata'
import { get_game_versions, get_loaders } from '@/helpers/tags'
import { injectInstanceSettings } from '@/providers/instance-settings'
import { useTheming } from '@/store/state'
import type { Manifest } from '../../../helpers/types'
@@ -43,8 +44,12 @@ const filePicker = injectFilePicker()
const { formatMessage } = useVIntl()
const queryClient = useQueryClient()
const debug = useDebugLogger('AppInstallationSettings')
const themeStore = useTheming()
const { instance, offline, isMinecraftServer, onUnlinked, closeModal } = injectInstanceSettings()
const skipNonEssentialWarnings = computed(() =>
themeStore.getFeatureFlag('skip_non_essential_warnings'),
)
debug('metadata load: start', {
instanceId: instance.value.id,
@@ -211,6 +216,7 @@ provideInstallationSettings({
reinstalling.value ||
!!offline,
),
skipNonEssentialWarnings,
modpack: computed(() => {
if (isImportedModpack.value && instance.value.link?.type === 'imported_modpack') {
return {
@@ -11,6 +11,7 @@ const themeStore = useTheming()
const { formatMessage } = useVIntl()
const worldsInHomeFlag: FeatureFlag = 'worlds_in_home'
const skipNonEssentialWarningsFlag: FeatureFlag = 'skip_non_essential_warnings'
const skipUnknownPackWarningFlag: FeatureFlag = 'skip_unknown_pack_warning'
const showPlayTimeFlag: FeatureFlag = 'show_instance_play_time'
@@ -101,6 +102,15 @@ const messages = defineMessages({
defaultMessage:
"If you attempt to install a Modrinth Pack file (.mrpack) that isn't hosted on Modrinth, we'll make sure you understand the risks before installing it.",
},
skipNonEssentialWarningsTitle: {
id: 'app.appearance-settings.skip-non-essential-warnings.title',
defaultMessage: 'Skip non-essential warnings',
},
skipNonEssentialWarningsDescription: {
id: 'app.appearance-settings.skip-non-essential-warnings.description',
defaultMessage:
'Automatically skips low-risk confirmations like duplicate modpack installs, normal content deletion, bulk updates, unlinking modpacks, and repair prompts. Dangerous warnings will still be shown.',
},
showPlayTimeTitle: {
id: 'app.appearance-settings.show-play-time.title',
defaultMessage: 'Show play time',
@@ -285,6 +295,25 @@ watch(
/>
</div>
<div class="mt-6 flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.skipNonEssentialWarningsTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.skipNonEssentialWarningsDescription) }}</p>
</div>
<Toggle
:model-value="themeStore.getFeatureFlag(skipNonEssentialWarningsFlag)"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag(skipNonEssentialWarningsFlag)
themeStore.featureFlags[skipNonEssentialWarningsFlag] = newValue
settings.feature_flags[skipNonEssentialWarningsFlag] = newValue
}
"
/>
</div>
<div class="mt-6 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
@@ -8,9 +8,12 @@ import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import { purge_cache_types } from '@/helpers/cache.js'
import { get, set } from '@/helpers/settings.ts'
import { showAppDbBackupsFolder } from '@/helpers/utils.js'
import { useTheming } from '@/store/state'
const { handleError } = injectNotificationManager()
const themeStore = useTheming()
const settings = ref(await get())
const purgeCacheConfirmModal = ref(null)
watch(
settings,
@@ -49,6 +52,15 @@ async function purgeCache() {
]).catch(handleError)
}
function handlePurgeCacheClick() {
if (themeStore.getFeatureFlag('skip_non_essential_warnings')) {
void purgeCache()
return
}
purgeCacheConfirmModal.value?.show()
}
async function openDbBackupsFolder() {
await showAppDbBackupsFolder().catch(handleError)
}
@@ -102,7 +114,7 @@ async function findLauncherDir() {
@proceed="purgeCache"
/>
<h2 class="m-0 text-lg font-semibold text-contrast">App cache</h2>
<button id="purge-cache" class="btn min-w-max" @click="$refs.purgeCacheConfirmModal.show()">
<button id="purge-cache" class="btn min-w-max" @click="handlePurgeCacheClick">
<TrashIcon />
Purge cache
</button>
@@ -2,6 +2,7 @@
import {
ClipboardCopyIcon,
EditIcon,
ExternalIcon,
EyeIcon,
FolderOpenIcon,
IssuesIcon,
@@ -23,6 +24,7 @@ import {
ButtonStyled,
commonMessages,
defineMessages,
injectNotificationManager,
OverflowMenu,
SmartClickable,
TagItem,
@@ -39,7 +41,7 @@ import type { Component } from 'vue'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { copyToClipboard } from '@/helpers/utils'
import { copyToClipboard, createInstanceShortcut } from '@/helpers/utils'
import type {
ProtocolVersion,
ServerStatus,
@@ -60,6 +62,7 @@ const formatDateTime = useFormatDateTime({
})
const router = useRouter()
const { addNotification } = injectNotificationManager()
const emit = defineEmits<{
(e: 'play' | 'play-instance' | 'update' | 'stop' | 'refresh' | 'edit' | 'delete'): void
@@ -94,6 +97,7 @@ const props = withDefaults(
instanceId?: string
instanceName?: string
instanceIcon?: string
shortcutInstanceId?: string
}>(),
{
playingInstance: false,
@@ -113,6 +117,7 @@ const props = withDefaults(
instanceId: undefined,
instanceName: undefined,
instanceIcon: undefined,
shortcutInstanceId: undefined,
},
)
@@ -131,6 +136,33 @@ const serverIncompatible = computed(
const locked = computed(() => props.world.type === 'singleplayer' && props.world.locked)
const managed = computed(() => props.managed)
const shortcutInstanceId = computed(() => props.shortcutInstanceId ?? props.instanceId)
async function createShortcut() {
if (!shortcutInstanceId.value) return
try {
const shortcutPath = await createInstanceShortcut(
props.world.name,
shortcutInstanceId.value,
props.world.type === 'server'
? { server: (props.world as ServerWorld).address }
: { singleplayerWorld: (props.world as SingleplayerWorld).path },
)
if (!shortcutPath) return
addNotification({
type: 'success',
title: 'Shortcut created',
})
} catch (error) {
addNotification({
type: 'error',
title: 'Failed to create shortcut',
text: error instanceof Error ? error.message : '',
})
}
}
const messages = defineMessages({
hardcore: {
@@ -185,6 +217,10 @@ const messages = defineMessages({
id: 'instance.worlds.dont_show_on_home',
defaultMessage: `Don't show on Home`,
},
createShortcut: {
id: 'instance.worlds.create_shortcut',
defaultMessage: 'Create shortcut',
},
linkedServer: {
id: 'instance.worlds.linked_server',
defaultMessage: 'Managed by server project',
@@ -473,6 +509,11 @@ const messages = defineMessages({
})
},
},
{
id: 'create-shortcut',
shown: !!shortcutInstanceId,
action: () => createShortcut(),
},
{
divider: true,
shown: !instanceId,
@@ -517,6 +558,10 @@ const messages = defineMessages({
<UpdatedIcon aria-hidden="true" />
{{ formatMessage(commonMessages.refreshButton) }}
</template>
<template #create-shortcut>
<ExternalIcon aria-hidden="true" />
{{ formatMessage(messages.createShortcut) }}
</template>
<template #dont-show-on-home>
<XIcon aria-hidden="true" />
{{ formatMessage(messages.dontShowOnHome) }}
+1
View File
@@ -97,6 +97,7 @@ type ReleaseChannel = 'release' | 'beta' | 'alpha'
export type InstanceLoader = 'vanilla' | 'forge' | 'fabric' | 'quilt' | 'neoforge'
type ContentFile = {
enabled: boolean
metadata?: {
project_id: string
version_id: string
+17
View File
@@ -1,4 +1,5 @@
import { invoke } from '@tauri-apps/api/core'
import { save } from '@tauri-apps/plugin-dialog'
import { get_full_path, get_mod_full_path } from '@/helpers/instance'
@@ -47,6 +48,22 @@ export async function showLauncherLogsFolder() {
return await invoke('plugin:utils|show_launcher_logs_folder', {})
}
export async function createInstanceShortcut(instanceName, instanceId, options = {}) {
const outputPath = await save({
defaultPath: `Modrinth - ${instanceName}`,
})
if (!outputPath) return null
return await invoke('plugin:shortcuts|create_instance_shortcut', {
instanceName,
instanceId,
outputPath,
server: options.server,
singleplayerWorld: options.singleplayerWorld,
})
}
export async function showAppDbBackupsFolder() {
return await invoke('plugin:utils|show_app_db_backups_folder', {})
}
@@ -128,6 +128,12 @@
"app.appearance-settings.show-play-time.title": {
"message": "Show play time"
},
"app.appearance-settings.skip-non-essential-warnings.description": {
"message": "Automatically skips low-risk confirmations like duplicate modpack installs, normal content deletion, bulk updates, unlinking modpacks, and repair prompts. Dangerous warnings will still be shown."
},
"app.appearance-settings.skip-non-essential-warnings.title": {
"message": "Skip non-essential warnings"
},
"app.appearance-settings.toggle-sidebar.description": {
"message": "Enables the ability to toggle the sidebar."
},
@@ -953,6 +959,9 @@
"instance.worlds.copy_address": {
"message": "Copy address"
},
"instance.worlds.create_shortcut": {
"message": "Create shortcut"
},
"instance.worlds.dont_show_on_home": {
"message": "Don't show on Home"
},
+3
View File
@@ -56,6 +56,7 @@ import {
provideServerInstallContent,
} from '@/providers/setup/server-install-content'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { useTheming } from '@/store/state'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -67,6 +68,7 @@ const debugLog = useDebugLogger('Browse')
const router = useRouter()
const route = useRoute()
const themeStore = useTheming()
const serverSetupModalRef = ref<InstanceType<typeof CreationFlowModal> | null>(null)
const serverInstallContent = createServerInstallContent({ serverSetupModalRef })
provideServerInstallContent(serverInstallContent)
@@ -577,6 +579,7 @@ const installContext = computed(() => {
queuedCount: queuedServerInstallCount.value,
selectedProjects: selectedServerInstallProjects.value,
isInstallingSelected: isInstallingQueuedServerInstalls.value,
skipNonEssentialWarnings: themeStore.getFeatureFlag('skip_non_essential_warnings'),
installProgress: queuedInstallProgress.value,
clearQueued: clearQueuedServerInstalls,
clearSelected: clearQueuedServerInstalls,
+26 -2
View File
@@ -197,6 +197,10 @@
id: 'export-mrpack',
action: () => exportModal?.show(),
},
{
id: 'create-shortcut',
action: () => createShortcut(),
},
]"
>
<MoreVerticalIcon />
@@ -204,6 +208,7 @@
<template #host-a-server> <ServerIcon /> Create a server </template>
<template #open-folder> <FolderOpenIcon /> Open folder </template>
<template #export-mrpack> <PackageIcon /> Export modpack </template>
<template #create-shortcut> <ExternalIcon /> Create shortcut </template>
</OverflowMenu>
</ButtonStyled>
</div>
@@ -321,7 +326,7 @@ import { get, get_full_path, kill, run } from '@/helpers/instance'
import { type InstanceContentData, loadInstanceContentData } from '@/helpers/instance-content'
import { get_by_instance_id } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types'
import { showInstanceInFolder } from '@/helpers/utils.js'
import { createInstanceShortcut, showInstanceInFolder } from '@/helpers/utils.js'
import { get_server_status, refreshWorlds } from '@/helpers/worlds'
import { injectServerInstall } from '@/providers/server-install'
import { handleSevereError } from '@/store/error.js'
@@ -330,7 +335,7 @@ import { useBreadcrumbs, useTheming } from '@/store/state'
dayjs.extend(duration)
dayjs.extend(relativeTime)
const { handleError } = injectNotificationManager()
const { addNotification, handleError } = injectNotificationManager()
const { playServerProject } = injectServerInstall()
const queryClient = useQueryClient()
const route = useRoute()
@@ -598,6 +603,25 @@ const repairInstance = async () => {
}
}
const createShortcut = async () => {
if (!instance.value) return
try {
const shortcutPath = await createInstanceShortcut(instance.value.name, instance.value.id)
if (!shortcutPath) return
addNotification({
type: 'success',
title: 'Shortcut created',
})
} catch (error: unknown) {
addNotification({
type: 'error',
title: `Error creating shortcut`,
text: `${error}`,
})
}
}
const handleRightClick = (event: MouseEvent) => {
const baseOptions = [
{ name: 'add_content' },
@@ -124,6 +124,7 @@ import { type InstanceContentData, loadInstanceContentData } from '@/helpers/ins
import type { CacheBehaviour, GameInstance } from '@/helpers/types'
import { highlightModInInstance } from '@/helpers/utils.js'
import { injectContentInstall } from '@/providers/content-install'
import { useTheming } from '@/store/state'
const messages = defineMessages({
shareTitle: {
@@ -173,6 +174,10 @@ const { installingItems, installRevisionByInstance, installFailureRevisionByInst
const router = useRouter()
const queryClient = useQueryClient()
const debug = useDebugLogger('Mods:ContentUpdate')
const themeStore = useTheming()
const skipNonEssentialWarnings = computed(() =>
themeStore.getFeatureFlag('skip_non_essential_warnings'),
)
const props = defineProps<{
instance: GameInstance
@@ -1059,7 +1064,7 @@ async function handleModpackUpdateRequest(selectedVersion: Labrinth.Versions.v2.
isModpackUpdateDowngrade.value ||
versionChangesGameVersion(selectedVersion, props.instance.game_version)
if (!shouldShowWarning) {
if (skipNonEssentialWarnings.value || !shouldShowWarning) {
await handleModpackUpdateConfirm()
return
}
@@ -1306,6 +1311,7 @@ provideContentManager({
isPackLocked,
isBusy: isInstanceBusy,
isBulkOperating,
skipNonEssentialWarnings,
contentTypeLabel: ref(formatMessage(messages.contentTypeProject)),
toggleEnabled: toggleDisableDebounced,
bulkEnableItems: (items: ContentItem[]) =>
@@ -98,6 +98,7 @@
world.type === 'server' ? serverData[world.address]?.renderedMotd : undefined
"
:game-mode="world.type === 'singleplayer' ? GAME_MODES[world.game_mode] : undefined"
:shortcut-instance-id="instance.id"
@play="() => joinWorld(world)"
@stop="() => emit('stop')"
@refresh="() => refreshServer((world as ServerWorld).address)"
@@ -33,6 +33,7 @@ import {
} from '@/helpers/instance'
import { get_game_versions } from '@/helpers/tags'
import type { GameInstance, InstanceLoader } from '@/helpers/types'
import { useTheming } from '@/store/state'
interface ModalRef {
show: (initialVersionId?: string) => void
hide: () => void
@@ -187,6 +188,7 @@ export function createContentInstall(opts: {
handleError: (err: unknown) => void
}): ContentInstallContext {
const { formatMessage } = useVIntl()
const themeStore = useTheming()
const instances = ref<ContentInstallInstance[]>([])
const compatibleLoaders = ref<string[]>([])
const gameVersions = ref<string[]>([])
@@ -810,7 +812,7 @@ export function createContentInstall(opts: {
const packs = await list()
const existingPack = packs.find((pack) => pack.link?.project_id === project.id)
if (existingPack) {
if (existingPack && !themeStore.getFeatureFlag('skip_non_essential_warnings')) {
pendingModpackInstall = { project, version, source, callback, createInstanceCallback }
modpackAlreadyInstalledModalRef?.show(existingPack.name, existingPack.id)
return
@@ -21,10 +21,12 @@ import {
import { list } from '@/helpers/instance'
import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata.js'
import type { InstanceLoader } from '@/helpers/types'
import { useTheming } from '@/store/state'
export function setupCreationModal(notificationManager: AbstractWebNotificationManager) {
const { handleError } = notificationManager
const router = useRouter()
const themeStore = useTheming()
const installationModal =
useTemplateRef<ComponentExposed<typeof CreationFlowModal>>('installationModal')
@@ -71,7 +73,7 @@ export function setupCreationModal(notificationManager: AbstractWebNotificationM
const instances = await list().catch(handleError)
const existingInstance = instances?.find((i) => i.link?.project_id === projectId)
if (existingInstance) {
if (existingInstance && !themeStore.getFeatureFlag('skip_non_essential_warnings')) {
pendingModpackCreation.value = { projectId, versionId, name, iconUrl }
installationModal.value?.hide()
modpackAlreadyInstalledModal.value?.show(existingInstance.name, existingInstance.id)
+1
View File
@@ -10,6 +10,7 @@ export const DEFAULT_FEATURE_FLAGS = {
server_project_qa: false,
server_ram_as_bytes_always_on: false,
always_show_app_controls: false,
skip_non_essential_warnings: false,
skip_unknown_pack_warning: false,
pride_fundraiser: true,
i18n_debug: false,
+1 -1
View File
@@ -47,7 +47,7 @@ async fn main() -> theseus::Result<()> {
for world in worlds {
println!(
"World: {:?}/{:?} played at {:?}: {:#?}",
world.profile,
world.instance_id,
world.world.name,
world.world.last_played,
world.world.details
+13 -11
View File
@@ -22,11 +22,6 @@ path-util = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
serde_with = { workspace = true }
tauri = { workspace = true, features = [
"devtools",
"macos-private-api",
"protocol-asset",
] }
tauri-plugin-deep-link = { workspace = true }
tauri-plugin-dialog = { workspace = true }
tauri-plugin-fs = { workspace = true }
@@ -45,6 +40,10 @@ url = { workspace = true }
urlencoding = { workspace = true }
uuid = { workspace = true, features = ["serde", "v4"] }
[dependencies.tauri]
workspace = true
features = ["devtools", "macos-private-api", "protocol-asset"]
[build-dependencies]
tauri-build = { workspace = true, features = ["codegen"] }
@@ -54,19 +53,22 @@ tauri-plugin-updater = { workspace = true, optional = true }
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation.workspace = true
core-graphics.workspace = true
objc2-app-kit = { workspace = true, features = [
"NSWindow",
] }
objc2-app-kit = { workspace = true, features = ["NSWindow"] }
[target.'cfg(windows)'.dependencies]
webview2-com.workspace = true
windows = { workspace = true, features = [
windows-core.workspace = true
[target.'cfg(windows)'.dependencies.windows]
workspace = true
features = [
"Win32_Foundation",
"Win32_Graphics_Dwm",
"Win32_Graphics_Gdi",
"Win32_System_Com",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
] }
windows-core.workspace = true
]
[features]
# by default Tauri runs in production mode
+8
View File
@@ -242,6 +242,14 @@ fn main() {
DefaultPermissionRule::AllowAllCommands,
),
)
.plugin(
"shortcuts",
InlinedPlugin::new()
.commands(&["create_instance_shortcut"])
.default_permission(
DefaultPermissionRule::AllowAllCommands,
),
)
.plugin(
"utils",
InlinedPlugin::new()
+1
View File
@@ -105,6 +105,7 @@
"cache:default",
"files:default",
"settings:default",
"shortcuts:default",
"tags:default",
"utils:default",
"ads:default",
+1
View File
@@ -13,6 +13,7 @@ pub mod minecraft_skins;
pub mod mr_auth;
pub mod process;
pub mod settings;
pub mod shortcuts;
pub mod tags;
pub mod utils;
+55
View File
@@ -0,0 +1,55 @@
use crate::api::Result;
use std::path::Path;
use url::Url;
pub(super) const SHORTCUT_EXTENSION: &str = "desktop";
pub(super) async fn create_shortcut(
profile_name: &str,
launch_url: &Url,
output_path: &Path,
) -> Result<()> {
let target_path = std::env::current_exe()?;
tokio::fs::write(
output_path,
format!(
"[Desktop Entry]\n\
Type=Application\n\
Name={}\n\
Exec={} {}\n\
Icon=ModrinthApp\n\
Terminal=false\n\
Categories=Game;\n",
escape_desktop_entry_value(&format!("Launch {profile_name}")),
quote_desktop_exec_arg(&target_path.to_string_lossy()),
quote_desktop_exec_arg(launch_url.as_str()),
),
)
.await?;
use std::os::unix::fs::PermissionsExt;
let mut permissions = tokio::fs::metadata(output_path).await?.permissions();
permissions.set_mode(0o755);
tokio::fs::set_permissions(output_path, permissions).await?;
Ok(())
}
fn escape_desktop_entry_value(input: &str) -> String {
input
.replace('\\', "\\\\")
.replace('\n', "\\n")
.replace('\r', "")
}
fn quote_desktop_exec_arg(input: &str) -> String {
format!(
"\"{}\"",
input
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('$', "\\$")
.replace('`', "\\`")
)
}
+90
View File
@@ -0,0 +1,90 @@
use crate::api::Result;
use std::{
hash::{DefaultHasher, Hash, Hasher},
path::Path,
};
use url::Url;
pub(super) const SHORTCUT_EXTENSION: &str = "app";
pub(super) async fn create_shortcut(
profile_name: &str,
launch_url: &Url,
output_path: &Path,
) -> Result<()> {
let contents_dir = output_path.join("Contents");
let macos_dir = contents_dir.join("MacOS");
let resources_dir = contents_dir.join("Resources");
tokio::fs::create_dir_all(&macos_dir).await?;
tokio::fs::create_dir_all(&resources_dir).await?;
let executable_path = macos_dir.join("launch");
tokio::fs::write(
&executable_path,
format!(
"#!/bin/sh\nexec /usr/bin/open {}\n",
shell_quote(launch_url.as_str()),
),
)
.await?;
tokio::fs::write(
resources_dir.join("icon.icns"),
include_bytes!("../../../icons/icon.icns"),
)
.await?;
tokio::fs::write(
contents_dir.join("Info.plist"),
format!(r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>launch</string>
<key>CFBundleIdentifier</key>
<string>{}</string>
<key>CFBundleIconFile</key>
<string>icon.icns</string>
<key>CFBundleName</key>
<string>{}</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
</dict>
</plist>
"#,
macos_shortcut_identifier(launch_url.as_str()),
escape_xml(&format!("Launch {profile_name}")),
),
)
.await?;
use std::os::unix::fs::PermissionsExt;
let mut permissions =
tokio::fs::metadata(&executable_path).await?.permissions();
permissions.set_mode(0o755);
tokio::fs::set_permissions(&executable_path, permissions).await?;
Ok(())
}
fn macos_shortcut_identifier(launch_url: &str) -> String {
let mut hasher = DefaultHasher::new();
launch_url.hash(&mut hasher);
format!("com.modrinth.instance-shortcut.{:x}", hasher.finish())
}
fn shell_quote(input: &str) -> String {
format!("'{}'", input.replace('\'', "'\\''"))
}
fn escape_xml(input: &str) -> String {
input
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
+114
View File
@@ -0,0 +1,114 @@
use crate::api::Result;
use std::path::{Path, PathBuf};
use tauri::Runtime;
use url::Url;
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(target_os = "windows")]
mod windows;
#[cfg(target_os = "linux")]
use linux::{SHORTCUT_EXTENSION, create_shortcut};
#[cfg(target_os = "macos")]
use macos::{SHORTCUT_EXTENSION, create_shortcut};
#[cfg(target_os = "windows")]
use windows::{SHORTCUT_EXTENSION, create_shortcut};
pub fn init<R: Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("shortcuts")
.invoke_handler(tauri::generate_handler![create_instance_shortcut])
.build()
}
#[tauri::command]
pub async fn create_instance_shortcut(
instance_name: String,
instance_id: String,
output_path: PathBuf,
server: Option<String>,
singleplayer_world: Option<String>,
) -> Result<PathBuf> {
if server.is_some() && singleplayer_world.is_some() {
return Err(std::io::Error::other(
"shortcut cannot launch both a server and a singleplayer world",
)
.into());
}
let launch_url =
instance_launch_url(instance_id, server, singleplayer_world);
let output_path = shortcut_path_with_extension(output_path);
let output_path_existed =
tokio::fs::try_exists(&output_path).await.unwrap_or(false);
if let Err(error) =
create_shortcut(&instance_name, &launch_url, &output_path).await
{
cleanup_shortcut_artifact(&output_path, output_path_existed).await;
return Err(error);
}
Ok(output_path)
}
fn instance_launch_url(
instance_id: String,
server: Option<String>,
singleplayer_world: Option<String>,
) -> Url {
let mut launch_url = Url::parse("modrinth://launch/instance")
.expect("static launch URL should parse");
launch_url
.path_segments_mut()
.expect("launch URL should support path segments")
.push(&instance_id);
if let Some(server) = server {
launch_url.query_pairs_mut().append_pair("server", &server);
} else if let Some(singleplayer_world) = singleplayer_world {
launch_url
.query_pairs_mut()
.append_pair("singleplayer_world", &singleplayer_world);
}
launch_url
}
fn shortcut_path_with_extension(mut path: PathBuf) -> PathBuf {
if path
.extension()
.is_none_or(|current_extension| current_extension != SHORTCUT_EXTENSION)
{
path.set_extension(SHORTCUT_EXTENSION);
}
path
}
async fn cleanup_shortcut_artifact(path: &Path, existed: bool) {
if existed {
return;
}
let result = match tokio::fs::metadata(path).await {
Ok(metadata) if metadata.is_dir() => {
tokio::fs::remove_dir_all(path).await
}
_ => tokio::fs::remove_file(path).await,
};
if let Err(error) = result
&& error.kind() != std::io::ErrorKind::NotFound
{
tracing::warn!(
"failed to clean up shortcut artifact {}: {}",
path.display(),
error
);
}
}
+125
View File
@@ -0,0 +1,125 @@
use crate::api::Result;
use std::{
os::windows::ffi::OsStrExt,
path::{Path, PathBuf},
};
use url::Url;
use windows::{
Win32::{
System::Com::{
CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED,
COINIT_DISABLE_OLE1DDE, CoCreateInstance, CoInitializeEx,
CoUninitialize, IPersistFile,
},
UI::Shell::{IShellLinkW, ShellLink},
},
core::{Interface, PCWSTR},
};
pub(super) const SHORTCUT_EXTENSION: &str = "lnk";
pub(super) async fn create_shortcut(
_profile_name: &str,
launch_url: &Url,
output_path: &Path,
) -> Result<()> {
let target_path = std::env::current_exe()?;
let working_dir = target_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_default();
let output_path = output_path.to_path_buf();
let launch_url = launch_url.to_string();
tokio::task::spawn_blocking(move || {
create_windows_shortcut(
output_path,
target_path,
working_dir,
launch_url,
)
})
.await
.map_err(|error| {
std::io::Error::other(format!(
"failed to join shortcut creation task: {error}"
))
})??;
Ok(())
}
fn create_windows_shortcut(
output_path: PathBuf,
target_path: PathBuf,
working_dir: PathBuf,
launch_url: String,
) -> std::io::Result<()> {
let output_path = windows_wide_path(&output_path);
let target_path = windows_wide_path(&target_path);
let working_dir = windows_wide_path(&working_dir);
let launch_url = windows_wide_string(&launch_url);
// SAFETY:
// - COM is initialized for this blocking thread before any COM object is created.
// - `_com` is declared before the COM interface values, so it is dropped
// after them and calls `CoUninitialize` only once they are released.
// - Every PCWSTR points to a NUL-terminated UTF-16 buffer that lives until
// each call using it has returned.
unsafe {
let init_result = CoInitializeEx(
None,
COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE,
);
windows_result(init_result.ok())?;
let _com = WindowsComGuard;
let shortcut: IShellLinkW = windows_result(CoCreateInstance(
&ShellLink,
None,
CLSCTX_INPROC_SERVER,
))?;
windows_result(shortcut.SetPath(windows_pcwstr(&target_path)))?;
windows_result(shortcut.SetArguments(windows_pcwstr(&launch_url)))?;
windows_result(
shortcut.SetWorkingDirectory(windows_pcwstr(&working_dir)),
)?;
windows_result(
shortcut.SetIconLocation(windows_pcwstr(&target_path), 0),
)?;
let persist_file: IPersistFile = windows_result(shortcut.cast())?;
windows_result(persist_file.Save(windows_pcwstr(&output_path), true))?;
}
Ok(())
}
fn windows_result<T>(result: windows::core::Result<T>) -> std::io::Result<T> {
result.map_err(std::io::Error::other)
}
struct WindowsComGuard;
impl Drop for WindowsComGuard {
fn drop(&mut self) {
unsafe {
CoUninitialize();
}
}
}
fn windows_wide_path(path: &Path) -> Vec<u16> {
path.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect()
}
fn windows_wide_string(value: &str) -> Vec<u16> {
value.encode_utf16().chain(std::iter::once(0)).collect()
}
fn windows_pcwstr(value: &[u16]) -> PCWSTR {
PCWSTR::from_raw(value.as_ptr())
}
+7
View File
@@ -140,11 +140,18 @@ pub async fn get_opening_command(
state: tauri::State<'_, crate::macos::deep_link::InitialPayload>,
) -> Result<Option<CommandPayload>> {
let payload = state.payload.lock().await;
let cmd_arg = std::env::args_os()
.nth(1)
.map(|path| path.to_string_lossy().to_string());
return if let Some(payload) = payload.as_ref() {
tracing::info!("opening command {payload}");
Ok(Some(handler::parse_command(payload).await?))
} else if let Some(cmd_arg) = cmd_arg {
tracing::info!("opening command {cmd_arg:?}");
Ok(Some(handler::parse_command(&cmd_arg).await?))
} else {
Ok(None)
};
+1
View File
@@ -241,6 +241,7 @@ fn main() {
.plugin(api::minecraft_skins::init())
.plugin(api::process::init())
.plugin(api::settings::init())
.plugin(api::shortcuts::init())
.plugin(api::tags::init())
.plugin(api::utils::init())
.plugin(api::cache::init())
@@ -27,7 +27,7 @@
<div
v-for="passkey in passkeys"
:key="passkey.id"
class="universal-card recessed passkey"
class="universal-card recessed flex items-center justify-between"
>
<div>
<div>
@@ -54,43 +54,45 @@
</span>
</div>
</div>
<div class="passkey-actions flex gap-2">
<ButtonStyled>
<div class="flex gap-2">
<ButtonStyled circular>
<button
v-tooltip="formatMessage(commonMessages.renameButton)"
@click="
() => {
passkeyToRename = { ...passkey }
$refs.renamePasskeyModal.show()
renamePasskeyModal?.show()
}
"
>
<EditIcon /> {{ formatMessage(commonMessages.renameButton) }}
<EditIcon />
</button>
</ButtonStyled>
<ButtonStyled>
<ButtonStyled circular>
<button
v-tooltip="formatMessage(commonMessages.removeButton)"
@click="
() => {
passkeyToRemove = passkey
$refs.removePasskeyModal.show()
removePasskeyModal?.show()
}
"
>
<TrashIcon /> {{ formatMessage(commonMessages.removeButton) }}
<TrashIcon />
</button>
</ButtonStyled>
</div>
</div>
</template>
<div class="input-group self-end">
<ButtonStyled color="brand">
<ButtonStyled>
<button @click="registerPasskey()">
<PlusIcon />
{{ formatMessage(messages.managePasskeyAddPasskey) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="$refs.managePasskeyModal.hide()">
<button @click="managePasskeyModal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.closeButton) }}
</button>
@@ -106,32 +108,30 @@
>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<label for="passkey-name">
<div class="text-md mb-2 font-semibold text-contrast">
{{ formatMessage(messages.passkeyNameLabel) }}
</div>
<div class="label__description mb-2">
{{ formatMessage(messages.passkeyNameDescription) }}
</div>
</label>
<div class="text-md font-semibold text-contrast">
{{ formatMessage(messages.passkeyNameLabel) }}
</div>
<StyledInput
id="passkey-name"
v-model="pendingPasskey.name"
v-model="pendingPasskeyName"
:maxlength="255"
type="text"
:placeholder="formatMessage(messages.passkeyNamePlaceholder)"
@keyup.enter="pendingPasskey.name && finishRegisterPasskey()"
@keyup.enter="pendingPasskeyName && finishRegisterPasskey()"
/>
<div class="label__description">
{{ formatMessage(messages.passkeyNameDescription) }}
</div>
</div>
<div class="input-group push-right">
<div class="flex justify-end gap-2.5">
<ButtonStyled>
<button @click="$refs.addPasskeyModal.hide()">
<button @click="addPasskeyModal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="!pendingPasskey.name" @click="finishRegisterPasskey()">
<button :disabled="!pendingPasskeyName" @click="finishRegisterPasskey()">
<PlusIcon />
{{ formatMessage(messages.managePasskeyAddPasskey) }}
</button>
@@ -145,34 +145,32 @@
width="500px"
:header="formatMessage(messages.renamePasskeyModalHeader)"
>
<div class="flex flex-col gap-6">
<div v-if="passkeyToRename" class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<label for="passkey-rename">
<div class="text-md mb-2 font-semibold text-contrast">
{{ formatMessage(messages.passkeyNameLabel) }}
</div>
<div class="label__description mb-2">
{{ formatMessage(messages.passkeyNameDescription) }}
</div>
</label>
<div class="text-md font-semibold text-contrast">
{{ formatMessage(messages.passkeyNameLabel) }}
</div>
<StyledInput
id="passkey-rename"
v-model="passkeyToRename.name"
v-model="passkeyToRenameName"
:maxlength="255"
type="text"
:placeholder="formatMessage(messages.passkeyNamePlaceholder)"
@keyup.enter="passkeyToRename.name && renamePasskey()"
@keyup.enter="passkeyToRenameName && renamePasskey()"
/>
<div class="label__description">
{{ formatMessage(messages.passkeyNameDescription) }}
</div>
</div>
<div class="input-group push-right">
<div class="flex justify-end gap-2.5">
<ButtonStyled>
<button @click="$refs.renamePasskeyModal.hide()">
<button @click="renamePasskeyModal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="!passkeyToRename.name" @click="renamePasskey()">
<button :disabled="!passkeyToRenameName" @click="renamePasskey()">
<SaveIcon />
{{ formatMessage(commonMessages.saveButton) }}
</button>
@@ -223,6 +221,7 @@ import {
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import { computed, ref, useTemplateRef } from 'vue'
import { createPasskeyCredential } from '~/helpers/passkey.ts'
@@ -262,7 +261,7 @@ const messages = defineMessages({
},
managePasskeyModalNoPasskeys: {
id: 'settings.account.security.passkey.modal.no-passkeys',
defaultMessage: 'You have no passkeys registered yet.',
defaultMessage: 'You do not have any passkeys registered.',
},
managePasskeyModalAdded: {
id: 'settings.account.security.passkey.modal.added',
@@ -304,13 +303,30 @@ const messages = defineMessages({
},
})
const managePasskeyModal = ref()
const renamePasskeyModal = ref()
const addPasskeyModal = ref()
const removePasskeyModal = useTemplateRef<InstanceType<typeof ConfirmModal>>('removePasskeyModal')
const managePasskeyModal = useTemplateRef<InstanceType<typeof NewModal>>('managePasskeyModal')
const addPasskeyModal = useTemplateRef<InstanceType<typeof NewModal>>('addPasskeyModal')
const renamePasskeyModal = useTemplateRef<InstanceType<typeof NewModal>>('renamePasskeyModal')
const pendingPasskey = ref<Labrinth.Auth.v2.PasskeyRegisterFinishRequest | null>(null)
const passkeyToRemove = ref<Labrinth.Auth.v2.Passkey | null>(null)
const passkeyToRename = ref<Labrinth.Auth.v2.Passkey | null>(null)
const pendingPasskeyName = computed({
get: () => pendingPasskey.value?.name ?? '',
set: (name) => {
if (pendingPasskey.value) {
pendingPasskey.value.name = name
}
},
})
const passkeyToRenameName = computed({
get: () => passkeyToRename.value?.name ?? '',
set: (name) => {
if (passkeyToRename.value) {
passkeyToRename.value.name = name
}
},
})
const passkeys = ref<Labrinth.Auth.v2.Passkey[]>([])
const passkeysLoading = ref(false)
@@ -326,7 +342,7 @@ async function fetchPasskeys() {
}
async function showPasskeyModal() {
managePasskeyModal.value.show()
managePasskeyModal.value?.show()
await fetchPasskeys()
}
@@ -342,7 +358,7 @@ async function registerPasskey() {
name: '',
}
addPasskeyModal.value.show()
addPasskeyModal.value?.show()
} catch (err) {
notifyError(err)
}
@@ -357,7 +373,7 @@ async function finishRegisterPasskey() {
const passkey = await labrinth.auth_v2.registerPasskeyFinish(pendingPasskey.value)
passkeys.value.unshift(passkey)
pendingPasskey.value = null
addPasskeyModal.value.hide()
addPasskeyModal.value?.hide()
} catch (err) {
notifyError(err)
}
@@ -376,7 +392,7 @@ async function renamePasskey() {
existing.name = name
}
passkeyToRename.value = null
renamePasskeyModal.value.hide()
renamePasskeyModal.value?.hide()
} catch (err) {
notifyError(err)
}
@@ -398,25 +414,3 @@ async function removePasskey() {
stopLoading()
}
</script>
<style lang="scss" scoped>
.passkey {
display: flex;
flex-direction: column;
gap: 0.5rem;
@media screen and (min-width: 650px) {
flex-direction: row;
align-items: center;
}
}
.passkey-actions {
flex-direction: row;
@media screen and (min-width: 650px) {
flex-direction: column;
margin-left: auto;
}
}
</style>
+1 -1
View File
@@ -4128,7 +4128,7 @@
"message": "Never used"
},
"settings.account.security.passkey.modal.no-passkeys": {
"message": "You have no passkeys registered yet."
"message": "You do not have any passkeys registered."
},
"settings.account.security.passkey.remove.description": {
"message": "This will permanently remove the passkey \"{name}\". You will no longer be able to sign in with it."
+4 -4
View File
@@ -33,7 +33,7 @@
@keyup.enter="saveEmail()"
/>
</div>
<div class="input-group push-right">
<div class="flex justify-end gap-2.5">
<ButtonStyled>
<button @click="$refs.changeEmailModal.hide()">
<XIcon />
@@ -128,7 +128,7 @@
</div>
</div>
</template>
<div class="input-group push-right">
<div class="flex justify-end gap-2.5">
<ButtonStyled>
<button @click="$refs.managePasswordModal.hide()">
<XIcon />
@@ -201,7 +201,7 @@
<p v-if="twoFactorIncorrect" class="known-errors m-0">
{{ formatMessage(messages.twoFactorIncorrectError) }}
</p>
<div class="input-group push-right">
<div class="flex justify-end gap-2.5">
<ButtonStyled>
<button @click="$refs.manageTwoFactorModal.hide()">
<XIcon />
@@ -279,7 +279,7 @@
<li v-for="code in backupCodes" :key="code">{{ code }}</li>
</ul>
</template>
<div class="input-group push-right">
<div class="flex justify-end gap-2.5">
<ButtonStyled v-if="twoFactorStep === 1">
<button @click="twoFactorStep = 0">
<LeftArrowIcon />
+8 -1
View File
@@ -526,4 +526,11 @@ yahoo.com, 100033, DIRECT, e1a5b5b6e3255540
#IndexExchange
indexexchange.com, 184169, DIRECT, 50b1c356f2c5c8fc
indexexchange.com, 198988, DIRECT, 50b1c356f2c5c8fc
indexexchange.com, 198988, DIRECT, 50b1c356f2c5c8fc
#AppNexus
Xandr.com, 2398, DIRECT
#Rubicon
rubiconproject.com, 24584, DIRECT, 0bfd66d529a55807
rubiconproject.com, 24586, DIRECT, 0bfd66d529a55807
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n\t\tUPDATE instances\n\t\tSET\n\t\t\tsubmitted_time_played = CASE\n\t\t\t\tWHEN submitted_time_played < 0 THEN ?\n\t\t\t\tWHEN submitted_time_played > ? THEN ?\n\t\t\t\tELSE submitted_time_played + ?\n\t\t\tEND,\n\t\t\trecent_time_played = 0,\n\t\t\tmodified = ?\n\t\tWHERE id = ?\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "3af5ce6fdc84ce12da06b92950d0249bdc4023c0b3d0c683318079ce93c9dd56"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n\t\tUPDATE instances\n\t\tSET\n\t\t\tsubmitted_time_played = submitted_time_played + ?,\n\t\t\trecent_time_played = 0,\n\t\t\tmodified = ?\n\t\tWHERE id = ?\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "44f82d6bbf66f685e20bee5c9c58587c035eb94098831c690739393870d4e814"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n\t\tUPDATE instances\n\t\tSET recent_time_played = recent_time_played + ?, modified = ?\n\t\tWHERE id = ?\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "8c0ef949dc78aa5d58b8fcd3e04c9c425cfac0520785d4e25d687c2d9ea82088"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n\t\tUPDATE instance_files\n\t\tSET\n\t\t\trelative_path = ?,\n\t\t\tfile_name = ?,\n\t\t\tenabled = ?,\n\t\t\tmissing = 0,\n\t\t\tmodified_at = ?\n\t\tWHERE instance_id = ? AND relative_path = ?\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "c5f78eaae528b6f930bcc9ade15384564c86cd98a1e407515ea718127a3b1777"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n\t\tUPDATE instances\n\t\tSET\n\t\t\trecent_time_played = CASE\n\t\t\t\tWHEN recent_time_played < 0 THEN ?\n\t\t\t\tWHEN recent_time_played > ? THEN ?\n\t\t\t\tELSE recent_time_played + ?\n\t\t\tEND,\n\t\t\tmodified = ?\n\t\tWHERE id = ?\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "d0bdfcba97a212ad80f56c1884ec2f984ce07a6d9498f19c24c6e2c031122fc5"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n\t\tUPDATE instance_files\n\t\tSET\n\t\t\trelative_path = ?,\n\t\t\tfile_name = ?,\n\t\t\tenabled = ?,\n\t\t\tmodified_at = ?\n\t\tWHERE instance_id = ? AND relative_path = ?\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "f93f5fca4f004dc70cab05f879993eb92fa6199c7356bbc2c5ca36468f916380"
}
+1
View File
@@ -105,6 +105,7 @@ tracing = { workspace = true }
tracing-error = { workspace = true }
tracing-subscriber = { workspace = true, features = ["chrono", "env-filter"] }
url = { workspace = true, features = ["serde"] }
urlencoding = { workspace = true }
uuid = { workspace = true, features = ["serde", "v4"] }
whoami = { workspace = true }
zbus = { workspace = true }
@@ -0,0 +1,12 @@
UPDATE instances
SET
submitted_time_played = CASE
WHEN submitted_time_played < 0 THEN 0
ELSE submitted_time_played
END,
recent_time_played = CASE
WHEN recent_time_played < 0 THEN 0
ELSE recent_time_played
END
WHERE submitted_time_played < 0
OR recent_time_played < 0;
+49
View File
@@ -7,6 +7,8 @@ use crate::{
},
util::io,
};
use url::form_urlencoded;
use urlencoding::decode;
/// Handles external functions (such as through URL deep linkage)
/// Link is extracted value (link) in somewhat URL format, such as
@@ -28,6 +30,53 @@ pub async fn handle_url(sublink: &str) -> crate::Result<CommandPayload> {
Some(("server", id)) => {
CommandPayload::InstallServer { id: id.to_string() }
}
// /launch/instance/{id} - Launches an instance
Some(("launch", rest)) if rest.starts_with("instance/") => {
let raw = rest.trim_start_matches("instance/");
let (raw, query) = raw.split_once('?').unwrap_or((raw, ""));
let mut server = None;
let mut singleplayer_world = None;
for (key, value) in form_urlencoded::parse(query.as_bytes()) {
match &*key {
"server" => server = Some(value.into_owned()),
"singleplayer_world" => {
singleplayer_world = Some(value.into_owned());
}
_ => {}
}
}
if server.is_some() && singleplayer_world.is_some() {
emit_warning(
"Invalid command, cannot launch both a server and a singleplayer world",
)
.await?;
return Err(crate::ErrorKind::InputError(
"Cannot launch both a server and a singleplayer world"
.to_string(),
)
.into());
}
match decode(raw) {
Ok(decoded) => CommandPayload::LaunchInstance {
id: decoded.to_string(),
server,
singleplayer_world,
},
Err(e) => {
emit_warning(&format!(
"Invalid UTF-8 in instance path: {e}"
))
.await?;
return Err(crate::ErrorKind::InputError(format!(
"Invalid UTF-8 in instance path: {e}"
))
.into());
}
}
}
_ => {
emit_warning(&format!(
"Invalid command, unrecognized path: {sublink}"
+27 -6
View File
@@ -250,9 +250,15 @@ impl Logs {
output: if clear_contents.unwrap_or(false) {
None
} else {
let state = State::get().await?;
Some(
get_output_by_filename(instance_path, log_type, &filename)
.await?,
get_output_by_filename_from_path(
&state,
instance_path,
log_type,
&filename,
)
.await?,
)
},
filename,
@@ -358,14 +364,12 @@ pub async fn get_logs_by_filename(
Logs::build(log_type, age, &instance_path, filename, Some(true)).await
}
#[tracing::instrument]
pub async fn get_output_by_filename(
async fn get_output_by_filename_from_path(
state: &State,
instance_path: &str,
log_type: LogType,
file_name: &str,
) -> crate::Result<CensoredString> {
let state = State::get().await?;
let logs_folder = match log_type {
LogType::InfoLog => state.directories.instance_logs_dir(instance_path),
LogType::CrashReport => {
@@ -409,6 +413,23 @@ pub async fn get_output_by_filename(
.into())
}
#[tracing::instrument]
pub async fn get_output_by_filename(
instance_id: &str,
log_type: LogType,
file_name: &str,
) -> crate::Result<CensoredString> {
let state = State::get().await?;
let instance_path = resolve_instance_path(instance_id, &state).await?;
get_output_by_filename_from_path(
&state,
&instance_path,
log_type,
file_name,
)
.await
}
#[tracing::instrument]
pub async fn delete_logs(instance_id: &str) -> crate::Result<()> {
let state = State::get().await?;
+5
View File
@@ -229,6 +229,11 @@ pub enum CommandPayload {
InstallServer {
id: String,
},
LaunchInstance {
id: String,
server: Option<String>,
singleplayer_world: Option<String>,
},
RunMRPack {
// run or install .mrpack
path: PathBuf,
@@ -117,6 +117,7 @@ impl ModLoader {
pub struct ContentFile {
pub hash: String,
pub file_name: String,
pub enabled: bool,
pub size: u64,
pub metadata: Option<FileMetadata>,
pub update_version_id: Option<String>,
@@ -627,6 +627,7 @@ pub(crate) async fn rename_instance_file(
relative_path = ?,
file_name = ?,
enabled = ?,
missing = 0,
modified_at = ?
WHERE instance_id = ? AND relative_path = ?
",
@@ -856,11 +857,11 @@ pub(crate) async fn set_content_entry_enabled_for_file(
file_id: &str,
enabled: bool,
pool: &SqlitePool,
) -> crate::Result<()> {
) -> crate::Result<bool> {
let enabled = i64::from(enabled);
let modified_at = Utc::now().timestamp();
sqlx::query!(
let result = sqlx::query!(
"
UPDATE instance_content_entries
SET enabled = ?, modified_at = ?
@@ -874,7 +875,7 @@ pub(crate) async fn set_content_entry_enabled_for_file(
.execute(pool)
.await?;
Ok(())
Ok(result.rows_affected() > 0)
}
pub(crate) async fn remove_content_entries_for_file(
@@ -3,7 +3,7 @@
use crate::state::instances::{
ContentSet, ContentSetStatus, ContentSourceKind, Instance,
InstanceLaunchContext, InstanceLaunchOverrides,
InstanceLaunchOverridesData, InstanceLink,
InstanceLaunchOverridesData, InstanceLink, playtime_to_storage,
};
use crate::state::{
InstanceInstallStage, LauncherFeatureVersion, ModLoader, ReleaseChannel,
@@ -831,8 +831,12 @@ pub(crate) async fn insert_instance(
let created = instance.created.timestamp();
let modified = instance.modified.timestamp();
let last_played = instance.last_played.map(|value| value.timestamp());
let submitted_time_played = instance.submitted_time_played as i64;
let recent_time_played = instance.recent_time_played as i64;
let submitted_time_played = playtime_to_storage(
instance.submitted_time_played,
"submitted_time_played",
)?;
let recent_time_played =
playtime_to_storage(instance.recent_time_played, "recent_time_played")?;
sqlx::query!(
"
@@ -887,8 +891,12 @@ pub(crate) async fn update_instance(
let icon_path = instance.icon_path.as_deref();
let modified = instance.modified.timestamp();
let last_played = instance.last_played.map(|value| value.timestamp());
let submitted_time_played = instance.submitted_time_played as i64;
let recent_time_played = instance.recent_time_played as i64;
let submitted_time_played = playtime_to_storage(
instance.submitted_time_played,
"submitted_time_played",
)?;
let recent_time_played =
playtime_to_storage(instance.recent_time_played, "recent_time_played")?;
sqlx::query!(
"
@@ -661,13 +661,31 @@ pub(crate) async fn toggle_disable_project(
}
None => index_existing_file(&scope, &new_path, state).await?,
};
content_rows::set_content_entry_enabled_for_file(
let updated_entry = content_rows::set_content_entry_enabled_for_file(
&scope.content_set_id,
&file.id,
enabled,
&state.pool,
)
.await?;
if !updated_entry {
let project_type = ProjectType::get_from_parent_folder(&new_path)
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Unable to infer project type from {new_path}"
))
})?;
upsert_entry_for_file(
&scope,
&file,
project_type,
None,
None,
ContentSourceKind::Local,
&state.pool,
)
.await?;
}
Ok(new_path)
}
@@ -281,12 +281,31 @@ async fn plan_bulk_update(
instance_id: &str,
state: &State,
) -> crate::Result<BulkUpdatePlan> {
let updateable_paths =
bulk_updateable_project_paths(instance_id, state).await?;
if updateable_paths.is_empty() {
return Ok(BulkUpdatePlan {
project_updates: Vec::new(),
dependency_additions: Vec::new(),
});
}
let updates = check_content_updates(
instance_id,
Some(CacheBehaviour::MustRevalidate),
state,
)
.await?;
.await?
.into_iter()
.filter(|update| updateable_paths.contains(&update.relative_path))
.collect::<Vec<_>>();
if updates.is_empty() {
return Ok(BulkUpdatePlan {
project_updates: Vec::new(),
dependency_additions: Vec::new(),
});
}
let content_set =
content_rows::get_applied_content_set(instance_id, &state.pool)
.await?
@@ -320,6 +339,7 @@ async fn plan_bulk_update(
.collect::<HashMap<_, _>>();
let version_ids = installed
.iter()
.filter(|project| updateable_paths.contains(&project.relative_path))
.filter_map(|project| project.version_id.clone())
.chain(
updates
@@ -343,6 +363,7 @@ async fn plan_bulk_update(
let planned_versions = installed
.iter()
.filter(|project| project.enabled)
.filter(|project| updateable_paths.contains(&project.relative_path))
.filter_map(|project| {
let target_version_id = updates_by_path
.get(&project.relative_path)
@@ -379,6 +400,21 @@ async fn plan_bulk_update(
})
}
async fn bulk_updateable_project_paths(
instance_id: &str,
state: &State,
) -> crate::Result<HashSet<String>> {
let items = super::list_content::list_content(
instance_id,
None,
Some(CacheBehaviour::MustRevalidate),
state,
)
.await?;
Ok(items.into_iter().map(|item| item.file_path).collect())
}
async fn installed_projects(
instance_id: &str,
content_set: &ContentSet,
@@ -1,6 +1,6 @@
use crate::state::InstanceInstallStage;
use crate::state::instances::{
InstanceLaunchContext, adapters::sqlite::instance_rows,
InstanceLaunchContext, adapters::sqlite::instance_rows, playtime_to_storage,
};
use chrono::{DateTime, Utc};
use sqlx::SqlitePool;
@@ -120,16 +120,31 @@ pub(crate) async fn add_instance_recent_playtime(
seconds: u64,
pool: &SqlitePool,
) -> crate::Result<()> {
let seconds = seconds as i64;
if seconds == 0 {
return Ok(());
}
let seconds = playtime_to_storage(seconds, "recent_time_played")?;
let max_playtime = i64::MAX;
let max_playtime_before_increment = max_playtime - seconds;
let modified = Utc::now().timestamp();
sqlx::query!(
"
UPDATE instances
SET recent_time_played = recent_time_played + ?, modified = ?
SET
recent_time_played = CASE
WHEN recent_time_played < 0 THEN ?
WHEN recent_time_played > ? THEN ?
ELSE recent_time_played + ?
END,
modified = ?
WHERE id = ?
",
seconds,
max_playtime_before_increment,
max_playtime,
seconds,
modified,
instance_id,
)
@@ -144,19 +159,33 @@ pub(crate) async fn mark_instance_playtime_submitted(
recent_time_played: u64,
pool: &SqlitePool,
) -> crate::Result<()> {
let recent_time_played = recent_time_played as i64;
if recent_time_played == 0 {
return Ok(());
}
let recent_time_played =
playtime_to_storage(recent_time_played, "recent_time_played")?;
let max_playtime = i64::MAX;
let max_playtime_before_increment = max_playtime - recent_time_played;
let modified = Utc::now().timestamp();
sqlx::query!(
"
UPDATE instances
SET
submitted_time_played = submitted_time_played + ?,
submitted_time_played = CASE
WHEN submitted_time_played < 0 THEN ?
WHEN submitted_time_played > ? THEN ?
ELSE submitted_time_played + ?
END,
recent_time_played = 0,
modified = ?
WHERE id = ?
",
recent_time_played,
max_playtime_before_increment,
max_playtime,
recent_time_played,
modified,
instance_id,
)
@@ -208,6 +208,8 @@ pub(crate) async fn list_content(
)
.await?;
let imported_modpack_scope = is_imported_modpack_scope(&resolved, &link);
let linked_modpack_source_kind = linked_modpack_source_kind(&link);
let mut failed_modpack_identifier_lookup = false;
let modpack_ids = if imported_modpack_scope {
None
} else {
@@ -226,6 +228,7 @@ pub(crate) async fn list_content(
"Failed to fetch modpack identifiers: {}",
err
);
failed_modpack_identifier_lookup = true;
None
}
},
@@ -240,6 +243,12 @@ pub(crate) async fn list_content(
}
} else if let Some(ids) = modpack_ids.as_ref() {
ContentFilter::ExcludeModpack(ids)
} else if failed_modpack_identifier_lookup {
ContentFilter::ExcludeSourceKind {
source_kind: linked_modpack_source_kind
.unwrap_or(ContentSourceKind::ModrinthModpack),
exclude_untracked: true,
}
} else {
ContentFilter::All
};
@@ -735,6 +744,9 @@ async fn content_projects_for_scope(
update_version_id,
hash: file.sha1,
file_name: file.file_name,
enabled: entry.map_or(file.enabled, |entry| {
entry.enabled && file.enabled
}),
size: file.size,
metadata: file_metadata_from_entry_or_cache(entry, metadata),
project_type,
@@ -882,7 +894,7 @@ async fn content_files_to_content_items(
file_path: path.clone(),
id: file.hash.clone(),
size: file.size,
enabled: !file.file_name.ends_with(".disabled"),
enabled: file.enabled,
project_type: file.project_type,
project: project.map(|project| ContentItemProject {
id: project.id.clone(),
@@ -1097,6 +1109,20 @@ fn linked_modpack_ids(link: &InstanceLink) -> Option<(String, String)> {
}
}
fn linked_modpack_source_kind(
link: &InstanceLink,
) -> Option<ContentSourceKind> {
match link {
InstanceLink::ModrinthModpack { .. } => {
Some(ContentSourceKind::ModrinthModpack)
}
InstanceLink::ServerProjectModpack { .. } => {
Some(ContentSourceKind::ServerProject)
}
_ => None,
}
}
fn check_modpack_update(
installed_version_id: &str,
installed_version: &Version,
@@ -39,9 +39,18 @@ pub(crate) async fn sync_instance_content_files(
&state.api_semaphore,
)
.await?;
let hashes_by_path = hashes
let hashes_by_key = hashes
.into_iter()
.map(|hash| (hash.path.clone(), hash))
.map(|hash| {
(
format!(
"{}-{}",
hash.size,
hash.path.trim_end_matches(".disabled")
),
hash,
)
})
.collect::<HashMap<_, _>>();
let existing_files =
sqlite::content_rows::get_instance_files(&instance.id, &state.pool)
@@ -55,8 +64,8 @@ pub(crate) async fn sync_instance_content_files(
let mut files = Vec::new();
for file in scanned {
let cache_path = format!("{}/{}", instance.path, file.relative_path);
let Some(hash) = hashes_by_path.get(&cache_path) else {
let hash_key = file.hash_cache_key.trim_end_matches(".disabled");
let Some(hash) = hashes_by_key.get(hash_key) else {
continue;
};
let existing_file = existing_files_by_path.get(&file.relative_path);
@@ -20,3 +20,15 @@ pub struct Instance {
pub submitted_time_played: u64,
pub recent_time_played: u64,
}
pub(crate) fn playtime_to_storage(
value: u64,
column: &str,
) -> crate::Result<i64> {
i64::try_from(value).map_err(|_| {
crate::ErrorKind::InputError(format!(
"Expected {column} to fit in SQLite INTEGER"
))
.into()
})
}
@@ -3,7 +3,7 @@ use crate::jre::check_jre;
use crate::prelude::ModLoader;
use crate::state;
use crate::state::instances::{
InstanceLaunchOverrides, InstanceLaunchOverridesData,
InstanceLaunchOverrides, InstanceLaunchOverridesData, playtime_to_storage,
};
use crate::state::{
CacheValue, CachedEntry, CachedFile, CachedFileHash, CachedFileUpdate,
@@ -443,8 +443,12 @@ where
let created = input.created.timestamp();
let modified = input.modified.timestamp();
let last_played = input.last_played.map(|value| value.timestamp());
let submitted_time_played = input.submitted_time_played as i64;
let recent_time_played = input.recent_time_played as i64;
let submitted_time_played = playtime_to_storage(
input.submitted_time_played,
"submitted_time_played",
)?;
let recent_time_played =
playtime_to_storage(input.recent_time_played, "recent_time_played")?;
sqlx::query!(
"
+28 -27
View File
@@ -17,6 +17,7 @@ use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::ExitStatus;
use std::sync::LazyLock;
use std::time::Instant;
#[cfg(feature = "tauri")]
use tauri::Emitter;
use tempfile::TempDir;
@@ -752,46 +753,46 @@ impl Process {
uuid: Uuid,
) -> crate::Result<()> {
async fn update_playtime(
last_updated_playtime: &mut DateTime<Utc>,
last_updated_playtime: &mut Instant,
instance_id: &str,
force_update: bool,
) {
let diff = Utc::now()
.signed_duration_since(*last_updated_playtime)
.num_seconds();
if diff >= 60 || force_update {
let state = match crate::State::get().await {
Ok(state) => state,
Err(e) => {
tracing::warn!(
"Failed to get state for playtime update on instance {}: {}",
instance_id,
e
);
return;
}
};
if let Err(e) =
crate::state::instances::commands::add_instance_recent_playtime(
instance_id,
diff as u64,
&state.pool,
)
.await
{
let elapsed = last_updated_playtime.elapsed().as_secs();
if elapsed == 0 || (!force_update && elapsed < 60) {
return;
}
let state = match crate::State::get().await {
Ok(state) => state,
Err(e) => {
tracing::warn!(
"Failed to update playtime for instance {}: {}",
"Failed to get state for playtime update on instance {}: {}",
instance_id,
e
);
return;
}
*last_updated_playtime = Utc::now();
};
if let Err(e) =
crate::state::instances::commands::add_instance_recent_playtime(
instance_id,
elapsed,
&state.pool,
)
.await
{
tracing::warn!(
"Failed to update playtime for instance {}: {}",
instance_id,
e
);
}
*last_updated_playtime = Instant::now();
}
// Wait on current Minecraft Child
let mc_exit_status;
let mut last_updated_playtime = Utc::now();
let mut last_updated_playtime = Instant::now();
let state = crate::State::get().await?;
loop {
+1
View File
@@ -63,6 +63,7 @@ pub enum FeatureFlag {
ServerProjectQa,
I18nDebug,
ShowInstancePlayTime,
SkipNonEssentialWarnings,
}
impl Settings {
+39
View File
@@ -10,6 +10,45 @@ export type VersionEntry = {
}
const VERSIONS: VersionEntry[] = [
{
date: `2026-06-25T21:22:14+00:00`,
product: 'app',
version: '0.15.0',
body: `## Added
- Existing app data is now backed up after the app updates.
- Added an option to open the folder which contains the app database backups in the Resource management tab in the app settings.
- Added an option to skip non-essential warnings across the app.
- Added the ability to create desktop shortcuts for your instances.
## Changed
- Instance installs in the Modrinth App now show detailed progress for preparing Java, downloading modpack files, downloading content, extracting overrides, and downloading Minecraft.
- Java, modpack, content, overrides and Minecraft downloads/extracting now show downloaded/extracted and total size.
- The Content tabs "Update all" action now shows live progress while resolving versions, downloading projects, and finishing updates.
- Changed the visual style of the Delete server/Delete world modal on the Worlds tab of instances to match the Content tab's modals.
## Fixed
- Fixed broken Delete server modal on the Worlds tab of instances.
- Fixed the navigation bars across the app not animating when clicking between pages.
- Fixed fade in animation incorrectly playing on the Mods page even when the instance content is already loaded into the UI.
- Failed or interrupted instance installs now roll back partial changes instead of leaving incomplete new instances or modified/broken existing instances behind.
- Failed or interrupted instance installs now explain why the failure or interruption occurred, and provide a retry action.
- The Home page, Library page, and the quick instance switcher in the left sidebar now correctly refresh when an instance begins or finishes installing.
- Fixed issue where going from an instance page to the browse page via the left sidebar would cause the app to completely freeze up.
- Fixed issue where using the "Add to instance" button on search results in the browse page would take a long time to process if you have a lot of instances.
- Fixed many performance issues with loading/editing of instances.`,
},
{
date: `2026-06-25T21:22:14+00:00`,
product: 'web',
body: `## Added
- Create account page where user can input username for new account.
- Log in with passkeys support, thanks [@DeDiamondPro](https://github.com/DeDiamondPro)!
## Changed
- Refreshed sign in and sign up page styles.
- Refreshed forget password and verify email pages.
- Updated account settings modals to match website styles for consistency.`,
},
{
date: `2026-06-24T00:30:00-07:00`,
product: 'web',
+115 -102
View File
@@ -148,122 +148,135 @@ defineExpose({
<template>
<NewModal ref="shareModal" :header="header" :noblur="noblur" :on-hide="onHide">
<div class="flex flex-col flex-wrap items-center gap-2">
<div v-if="link" class="group relative mx-auto">
<div ref="qrCode">
<QrcodeVue :value="url" class="!bg-white rounded-[var(--radius-md)]" margin="3" />
</div>
<ButtonStyled circular type="transparent">
<button
v-tooltip="'Copy QR code'"
class="absolute top-0 right-0 m-2"
aria-label="Copy QR code"
@click="copyImage"
>
<ClipboardCopyIcon class="h-5 w-5" aria-hidden="true" />
</button>
</ButtonStyled>
</div>
<StyledInput
v-else
v-model="content"
multiline
resize="vertical"
wrapper-class="h-full w-[30rem]"
<div class="flex flex-col items-center gap-2">
<div
:class="['flex items-center justify-center', link ? 'flex-wrap gap-4' : 'flex-col gap-2']"
>
<template #right>
<div v-if="link" class="group relative shrink-0">
<div ref="qrCode">
<QrcodeVue :value="url" class="!bg-white rounded-[var(--radius-md)]" margin="3" />
</div>
<ButtonStyled circular type="transparent">
<button
v-tooltip="'Copy Text'"
type="button"
aria-label="Copy Text"
v-tooltip="'Copy QR code'"
class="absolute top-0 right-0 m-2"
@click="copyText"
aria-label="Copy QR code"
@click="copyImage"
>
<ClipboardCopyIcon class="h-5 w-5" aria-hidden="true" />
</button>
</ButtonStyled>
</template>
</StyledInput>
<div class="flex flex-grow flex-col justify-center gap-2">
<button
v-if="link"
v-tooltip="'Copy Link'"
type="button"
aria-label="Copy Link"
class="flex h-10 w-full cursor-pointer items-center justify-between gap-2 rounded-xl border-none bg-button-bg px-3 pr-1.5 text-primary transition-all hover:bg-button-bg-hover hover:brightness-125 active:scale-95"
@click="copyText"
</div>
<StyledInput
v-else
v-model="content"
multiline
resize="vertical"
wrapper-class="h-full w-[30rem]"
>
<span class="cursor-pointer truncate text-left font-semibold text-primary">
{{ url }}
</span>
<div class="grid h-10 w-10 place-content-center">
<ClipboardCopyIcon class="h-5 w-5" aria-hidden="true" />
</div>
</button>
<ButtonStyled v-if="link">
<a :href="url" target="_blank" rel="noopener noreferrer" aria-label="Open in new tab">
Open in new tab
<ExternalIcon aria-hidden="true" />
</a>
</ButtonStyled>
<div v-if="socialButtons" class="flex flex-row gap-1">
<ButtonStyled v-if="canShare" circular>
<button v-tooltip="'Share'" aria-label="Share" @click="share">
<ShareIcon aria-hidden="true" />
</button>
</ButtonStyled>
<ButtonStyled circular>
<template #right>
<ButtonStyled circular type="transparent">
<button
v-tooltip="'Copy Text'"
type="button"
aria-label="Copy Text"
class="absolute top-0 right-0 m-2"
@click="copyText"
>
<ClipboardCopyIcon class="h-5 w-5" aria-hidden="true" />
</button>
</ButtonStyled>
</template>
</StyledInput>
<div
v-if="link || socialButtons"
:class="['flex flex-col justify-center gap-2', link ? 'w-64 max-w-full' : 'flex-grow']"
>
<button
v-if="link"
v-tooltip="'Copy Link'"
type="button"
aria-label="Copy Link"
class="flex h-10 w-full cursor-pointer items-center justify-between gap-2 rounded-xl border-none bg-button-bg px-3 pr-1.5 text-primary transition-all hover:bg-button-bg-hover hover:brightness-125 active:scale-95"
@click="copyText"
>
<span class="min-w-0 cursor-pointer truncate text-left font-semibold text-primary">
{{ url }}
</span>
<div class="grid h-10 w-10 place-content-center">
<ClipboardCopyIcon class="h-5 w-5" aria-hidden="true" />
</div>
</button>
<ButtonStyled v-if="link">
<a
v-tooltip="'Send as an email'"
:href="sendEmail"
:target="targetParameter"
aria-label="Send as an email"
>
<MailIcon aria-hidden="true" />
</a>
</ButtonStyled>
<ButtonStyled circular>
<a
v-if="link"
v-tooltip="'Open link in browser'"
:target="targetParameter"
:href="url"
aria-label="Open link in browser"
target="_blank"
rel="noopener noreferrer"
aria-label="Open in new tab"
class="w-full"
>
<GlobeIcon aria-hidden="true" />
</a>
</ButtonStyled>
<ButtonStyled circular>
<a
v-tooltip="'Toot about it'"
:target="targetParameter"
:href="sendToot"
aria-label="Toot about it"
>
<MastodonIcon aria-hidden="true" />
</a>
</ButtonStyled>
<ButtonStyled circular>
<a
v-tooltip="'Tweet about it'"
:target="targetParameter"
:href="sendTweet"
aria-label="Tweet about it"
>
<TwitterIcon aria-hidden="true" />
</a>
</ButtonStyled>
<ButtonStyled circular>
<a
v-tooltip="'Share on Reddit'"
:target="targetParameter"
:href="postOnReddit"
aria-label="Share on Reddit"
>
<RedditIcon aria-hidden="true" />
Open in new tab
<ExternalIcon aria-hidden="true" />
</a>
</ButtonStyled>
<div v-if="socialButtons" class="flex flex-row gap-1">
<ButtonStyled v-if="canShare" circular>
<button v-tooltip="'Share'" aria-label="Share" @click="share">
<ShareIcon aria-hidden="true" />
</button>
</ButtonStyled>
<ButtonStyled circular>
<a
v-tooltip="'Send as an email'"
:href="sendEmail"
:target="targetParameter"
aria-label="Send as an email"
>
<MailIcon aria-hidden="true" />
</a>
</ButtonStyled>
<ButtonStyled circular>
<a
v-if="link"
v-tooltip="'Open link in browser'"
:target="targetParameter"
:href="url"
aria-label="Open link in browser"
>
<GlobeIcon aria-hidden="true" />
</a>
</ButtonStyled>
<ButtonStyled circular>
<a
v-tooltip="'Toot about it'"
:target="targetParameter"
:href="sendToot"
aria-label="Toot about it"
>
<MastodonIcon aria-hidden="true" />
</a>
</ButtonStyled>
<ButtonStyled circular>
<a
v-tooltip="'Tweet about it'"
:target="targetParameter"
:href="sendTweet"
aria-label="Tweet about it"
>
<TwitterIcon aria-hidden="true" />
</a>
</ButtonStyled>
<ButtonStyled circular>
<a
v-tooltip="'Share on Reddit'"
:target="targetParameter"
:href="postOnReddit"
aria-label="Share on Reddit"
>
<RedditIcon aria-hidden="true" />
</a>
</ButtonStyled>
</div>
</div>
</div>
</div>
@@ -55,6 +55,11 @@ async function handleBack() {
if (!context) return
if (selectedCount.value > 0 && !isInstallingSelected.value) {
if (context.skipNonEssentialWarnings) {
await handleSelectedProjectsLeaveResult('discard', context)
return
}
const result = await selectedProjectsLeaveModal.value?.prompt()
await handleSelectedProjectsLeaveResult(result ?? 'cancel', context)
return
@@ -36,6 +36,7 @@ export interface BrowseInstallContext {
onBack?: () => boolean | void | Promise<boolean | void>
selectedProjects?: BrowseSelectedProject[]
isInstallingSelected?: boolean
skipNonEssentialWarnings?: boolean
installProgress?: {
completed: number
total: number
@@ -151,6 +151,7 @@ const messages = defineMessages({
})
const ctx = injectContentManager()
const skipNonEssentialWarnings = computed(() => ctx.skipNonEssentialWarnings?.value ?? false)
function getItemId(item: ContentItem) {
return ctx.getItemId?.(item) ?? item.file_path ?? item.file_name ?? item.id
@@ -389,7 +390,7 @@ async function promptDeleteItems(items: ContentItem[], event?: MouseEvent) {
}
function showDeletionConfirmation(event?: MouseEvent) {
if (event?.shiftKey && !ctx.isBusy.value) {
if ((event?.shiftKey || skipNonEssentialWarnings.value) && !ctx.isBusy.value) {
confirmDelete()
} else {
confirmDeletionModal.value?.show()
@@ -581,7 +582,7 @@ function promptUpdateAll(event?: MouseEvent) {
if (items.length === 0) return
pendingBulkUpdateItems.value = items
pendingBulkUpdateAll.value = true
if (event?.shiftKey && !ctx.isBusy.value) {
if ((event?.shiftKey || skipNonEssentialWarnings.value) && !ctx.isBusy.value) {
confirmBulkUpdate()
} else {
confirmBulkUpdateModal.value?.show()
@@ -594,7 +595,7 @@ function promptUpdateSelected(event?: MouseEvent) {
if (items.length === 0) return
pendingBulkUpdateItems.value = items
pendingBulkUpdateAll.value = false
if (event?.shiftKey && !ctx.isBusy.value) {
if ((event?.shiftKey || skipNonEssentialWarnings.value) && !ctx.isBusy.value) {
confirmBulkUpdate()
} else {
confirmBulkUpdateModal.value?.show()
@@ -47,6 +47,7 @@ export interface ContentManagerContext {
// Guards
isBusy: Ref<boolean> | ComputedRef<boolean>
busyMessage?: Ref<string | null> | ComputedRef<string | null>
skipNonEssentialWarnings?: Ref<boolean> | ComputedRef<boolean>
disableAddContent?: Ref<boolean> | ComputedRef<boolean>
disableAddContentTooltip?: string
@@ -43,6 +43,7 @@ import type { LoaderVersionEntry } from './types'
const { formatMessage } = useVIntl()
const ctx = injectInstallationSettings()
const debug = useDebugLogger('InstallationSettingsLayout')
const skipNonEssentialWarnings = computed(() => ctx.skipNonEssentialWarnings?.value ?? false)
const availablePlatforms = computed(() =>
Array.isArray(ctx.availablePlatforms) ? ctx.availablePlatforms : ctx.availablePlatforms.value,
)
@@ -228,7 +229,7 @@ function handleModpackUpdateRequest(version: Labrinth.Versions.v2.Version, event
isUpdateDowngrade.value ||
versionChangesGameVersion(version, ctx.updaterModalProps.value.currentGameVersion)
if (event?.shiftKey || !shouldShowWarning) {
if (event?.shiftKey || skipNonEssentialWarnings.value || !shouldShowWarning) {
debug('handleModpackUpdateRequest: confirming without warning', {
isUpdateDowngrade: isUpdateDowngrade.value,
shouldShowWarning,
@@ -371,6 +372,10 @@ function handleShowRepairModal() {
snapshot: stateSnapshot(),
refs: modalRefsSnapshot(),
})
if (skipNonEssentialWarnings.value) {
handleRepair()
return
}
repairModal.value?.show()
nextTick(() => {
debug('handleShowRepairModal: after nextTick', {
@@ -386,7 +391,7 @@ function handleShowUnlinkModal(event: MouseEvent) {
snapshot: stateSnapshot(),
refs: modalRefsSnapshot(),
})
if (event.shiftKey) {
if (event.shiftKey || skipNonEssentialWarnings.value) {
handleUnlink()
return
}
@@ -17,6 +17,7 @@ export interface InstallationSettingsContext {
isLinked: ComputedRef<boolean>
isBusy: Ref<boolean> | ComputedRef<boolean>
busyMessage?: Ref<string | null> | ComputedRef<string | null>
skipNonEssentialWarnings?: Ref<boolean> | ComputedRef<boolean>
modpack: Ref<InstallationModpackData | null> | ComputedRef<InstallationModpackData | null>
+1 -1
View File
@@ -237,7 +237,7 @@
"defaultMessage": "Re-install modpack"
},
"button.remove": {
"defaultMessage": "Remove"
"defaultMessage": "Delete passkey"
},
"button.remove-image": {
"defaultMessage": "Remove image"
+1 -1
View File
@@ -245,7 +245,7 @@ export const commonMessages = defineMessages({
},
removeButton: {
id: 'button.remove',
defaultMessage: 'Remove',
defaultMessage: 'Delete passkey',
},
removeImageButton: {
id: 'button.remove-image',