diff --git a/.github/workflows/check-generic.yml b/.github/workflows/check-generic.yml index 1953ccedf4..25fc4ad8ef 100644 --- a/.github/workflows/check-generic.yml +++ b/.github/workflows/check-generic.yml @@ -20,8 +20,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: taiki-e/install-action@fd2f5e3d644b484055ebf4268f474c565f148f25 # v2.81.9 + - uses: taiki-e/install-action@b6ff580856c41316412a0b9b60540fbc6f8c82cc # v2.86.7 with: - tool: tombi + tool: tombi@1.4.1 - run: tombi lint - run: tombi fmt --check diff --git a/.github/workflows/frontend-deploy.yml b/.github/workflows/frontend-deploy.yml index 703431d240..79b73c57ef 100644 --- a/.github/workflows/frontend-deploy.yml +++ b/.github/workflows/frontend-deploy.yml @@ -125,8 +125,7 @@ jobs: run: pnpm install - name: Build frontend - working-directory: ./apps/frontend - run: pnpm build + run: pnpm web:build env: CF_PAGES_BRANCH: ${{ inputs.head_ref || github.ref_name }} CF_PAGES_COMMIT_SHA: ${{ inputs.head_sha || github.sha }} diff --git a/.github/workflows/frontend-docker.yml b/.github/workflows/frontend-docker.yml new file mode 100644 index 0000000000..cd63875f11 --- /dev/null +++ b/.github/workflows/frontend-docker.yml @@ -0,0 +1,99 @@ +name: Frontend Docker + +on: + push: + branches: + - main + - prod + paths: + - 'apps/frontend/**/*' + - 'packages/ui/**/*' + - 'packages/utils/**/*' + - 'packages/assets/**/*' + - 'packages/moderation/**/*' + - 'packages/api-client/**/*' + - 'packages/blog/**/*' + - '**/pnpm-*.yaml' + - .github/workflows/frontend-docker.yml + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/prod' }} + +jobs: + build: + name: Build frontend + runs-on: namespace-profile-modrinth-frontend + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Configure environment + id: meta + env: + REF: ${{ github.ref }} + run: | + if [ "$REF" = "refs/heads/main" ]; then + echo "env=staging" >> $GITHUB_OUTPUT + else + echo "env=production" >> $GITHUB_OUTPUT + fi + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .nvmrc + + - name: Enable Corepack + run: corepack enable + + - name: Set up caches + uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1 + with: + cache: | + pnpm + + - name: Inject build variables + working-directory: ./apps/frontend + run: | + if [ "${{ steps.meta.outputs.env }}" == "staging" ]; then + echo "Injecting staging variables from wrangler.jsonc..." + jq -r '.env.staging.vars | to_entries[] | "\(.key)=\(.value)"' wrangler.jsonc >> $GITHUB_ENV + else + echo "Injecting production variables from wrangler.jsonc..." + jq -r '.vars | to_entries[] | "\(.key)=\(.value)"' wrangler.jsonc >> $GITHUB_ENV + fi + + - name: Install dependencies + working-directory: ./apps/frontend + run: pnpm install + + - name: Build frontend + run: pnpm web:build + env: + NITRO_PRESET: node-server + BUILD_ENV: ${{ steps.meta.outputs.env }} + CF_PAGES_BRANCH: ${{ github.ref_name }} + CF_PAGES_COMMIT_SHA: ${{ github.sha }} + + - name: Stage Docker context + run: | + mkdir -p apps/frontend/docker-stage + cp -r apps/frontend/.output apps/frontend/docker-stage/.output + + - name: Upload Docker context + uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1.0.3 + with: + name: frontend-docker-context + retention-days: 1 + path: apps/frontend/docker-stage + + docker-build: + needs: [build] + uses: SparkUniverse/workflows/.github/workflows/docker-build.yaml@main + with: + image-name: frontend + dockerfile-path: apps/frontend/Dockerfile + artifacts-name: frontend-docker-context diff --git a/.github/workflows/frontend-preview.yml b/.github/workflows/frontend-preview.yml index df17e1786c..16e76136fd 100644 --- a/.github/workflows/frontend-preview.yml +++ b/.github/workflows/frontend-preview.yml @@ -70,10 +70,9 @@ jobs: run: pnpm install - name: Build Storybook - working-directory: ./packages/ui env: NODE_OPTIONS: --max-old-space-size=6144 - run: pnpm run build-storybook + run: pnpm build-storybook - name: Configure short SHA id: meta diff --git a/.github/workflows/i18n-pull.yml b/.github/workflows/i18n-pull.yml index df5ceffa58..185f7815e6 100644 --- a/.github/workflows/i18n-pull.yml +++ b/.github/workflows/i18n-pull.yml @@ -8,6 +8,10 @@ on: concurrency: group: i18n-management +permissions: + contents: write + pull-requests: write + jobs: pull_translations: name: 'Pull translations from Crowdin' @@ -38,23 +42,17 @@ jobs: flight_failure "CROWDIN_PERSONAL_TOKEN secret is not defined (required to push)" fi - if [ "$CROWDIN_GH_TOKEN_DEFINED" != true ]; then - flight_failure "CROWDIN_GH_TOKEN secret is not defined (required to make pull requests)" - fi - if [ "$PREFLIGHT_CHECK_RESULT" = false ]; then exit 1 fi env: CROWDIN_PROJECT_ID_DEFINED: ${{ vars.CROWDIN_PROJECT_ID != '' }} CROWDIN_PERSONAL_TOKEN_DEFINED: ${{ secrets.CROWDIN_PERSONAL_TOKEN != '' }} - CROWDIN_GH_TOKEN_DEFINED: ${{ secrets.CROWDIN_GH_TOKEN != '' }} - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.ref }} - token: ${{ secrets.CROWDIN_GH_TOKEN }} - name: Setup Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -67,12 +65,6 @@ jobs: - name: Install script dependencies run: pnpm install --frozen-lockfile --ignore-scripts - - name: Configure Git author - id: git-author - uses: MarcoIeni/git-config@59144859caf016f8b817a2ac9b051578729173c4 # v0.1.2 - env: - GITHUB_TOKEN: ${{ secrets.CROWDIN_GH_TOKEN }} - # # Because --all flag of Crowdin CLI is currently broken we need to create a fake source file # # so that the CLI won't omit translations for it. See https://github.com/crowdin/crowdin-cli/issues/724 # - name: Write fake sources @@ -116,7 +108,4 @@ jobs: body-path: .github/templates/crowdin-pr.md commit-message: 'New translations from Crowdin (${{ steps.branch-name.outputs.branch_name }})' branch: crowdin-pull/${{ steps.branch-name.outputs.branch_name }} - author: '${{ steps.git-author.outputs.name }} <${{ steps.git-author.outputs.email }}>' - committer: '${{ steps.git-author.outputs.name }} <${{ steps.git-author.outputs.email }}>' labels: sync - token: ${{ secrets.CROWDIN_GH_TOKEN }} diff --git a/.gitignore b/.gitignore index 9d27dcaf5e..d442251099 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ Thumbs.db # frontend generated files apps/frontend/src/generated +/packages/ui/src/layouts/wrapped/settings/language-settings/language-settings-coverage.generated.ts .turbo target diff --git a/.idea/code.iml b/.idea/code.iml index 3861923c60..a1d7c854b4 100644 --- a/.idea/code.iml +++ b/.idea/code.iml @@ -28,6 +28,7 @@ + diff --git a/Cargo.lock b/Cargo.lock index 39f5e354f4..51fb4abe6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2239,6 +2239,16 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "component-derive" +version = "0.0.0" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "compression-codecs" version = "0.4.31" @@ -2682,10 +2692,10 @@ dependencies = [ "futures", "indexmap 2.11.4", "itertools 0.14.0", + "quick-xml 0.38.3", "reqwest 0.12.24", "rust-s3", "serde", - "serde-xml-rs", "serde_json", "sha1_smol", "thiserror 2.0.17", @@ -5497,6 +5507,7 @@ dependencies = [ "clickhouse", "color-eyre", "color-thief", + "component-derive", "const_format", "dashmap", "derive_more 2.1.1", @@ -5574,16 +5585,6 @@ dependencies = [ "zxcvbn", ] -[[package]] -name = "labrinth-derive" -version = "0.0.0" -dependencies = [ - "darling 0.23.0", - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "language-tags" version = "0.3.2" @@ -9449,18 +9450,6 @@ dependencies = [ "typeid", ] -[[package]] -name = "serde-xml-rs" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53630160a98edebde0123eb4dfd0fce6adff091b2305db3154a9e920206eb510" -dependencies = [ - "log", - "serde", - "thiserror 1.0.69", - "xml-rs", -] - [[package]] name = "serde_bytes" version = "0.11.19" diff --git a/Cargo.toml b/Cargo.toml index 0283fe8182..c6483af252 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,8 +8,8 @@ members = [ "packages/aditude", "packages/app-lib", "packages/ariadne", + "packages/component-derive", "packages/daedalus", - "packages/labrinth-derive", "packages/modrinth-content-management", "packages/modrinth-log", "packages/modrinth-maxmind", @@ -43,9 +43,7 @@ async-stripe = { version = "0.41.0", default-features = false, features = [ "runtime-tokio-hyper-rustls", ] } async-trait = "0.1.89" -async-tungstenite = { version = "0.31.0", default-features = false, features = [ - "futures-03-sink" -] } +async-tungstenite = { version = "0.31.0", default-features = false, features = ["futures-03-sink"] } async-walkdir = "2.1.0" async_zip = "0.0.18" aws-sdk-s3 = { version = "=1.122.0", default-features = false, features = [ @@ -63,13 +61,12 @@ censor = "0.3.0" chardetng = "0.1.17" chrono = "0.4.42" chumsky = "0.9.3" -cidre = { version = "0.15.0", default-features = false, features = [ - "macos_15_0" -] } +cidre = { version = "0.15.0", default-features = false, features = ["macos_15_0"] } clap = "4.5.48" clickhouse = "0.14.0" color-eyre = "0.6.5" color-thief = "0.2.2" +component-derive = { path = "packages/component-derive" } const_format = "0.2.34" core-foundation = "0.10.1" core-graphics = "0.24.0" @@ -164,10 +161,7 @@ redis = "1.4.1" regex = "1.12.2" reqwest = { version = "0.12.24", default-features = false } rgb = "0.8.52" -rust_decimal = { version = "1.39.0", features = [ - "serde-with-float", - "serde-with-str" -] } +rust_decimal = { version = "1.39.0", features = ["serde-with-float", "serde-with-str"] } rust_iso3166 = "0.1.14" rust-s3 = { version = "0.37.0", default-features = false, features = [ "fail-on-err", @@ -193,7 +187,6 @@ serde_cbor = "0.11.2" serde_ini = "0.2.0" serde_json = "1.0.145" serde_with = "3.15.0" -serde-xml-rs = "0.8.1" # Also an XML (de)serializer, consider dropping yaserde in favor of this sha1 = "0.10.6" sha1_smol = { version = "1.0.1", features = ["std"] } sha2 = "0.10.9" diff --git a/_typos.toml b/_typos.toml index 7775afa964..944064d355 100644 --- a/_typos.toml +++ b/_typos.toml @@ -6,13 +6,16 @@ extend-exclude = [ "packages/utils/", "packages/ui/", "packages/blog/", - # contains licenses like `CC-BY-ND-4.0` - "packages/moderation/src/data/stages/license.ts", - "packages/moderation/src/utils.ts", - # contains payment card IDs like `IY1VMST1MOXS` which are flagged - "apps/labrinth/src/queue/payouts/mod.rs", - # contains domain names with deliberate typos - "apps/labrinth/assets/disposable_email_blocklist.txt" +] + +[default] +extend-ignore-re = [ + # Ignore lines that end with `// typos:ignore` + "(?Rm)^.*(#|//)\\s*typos:ignore$", + # Ignore the line after `// typos:ignore`: + "(#|//)\\s*typos:ignore\\n.*", + # Ignore blocks between `// typos:off` and `// typos:on` + "(?s)(#|//)\\s*typos:off.*?\\n\\s*(#|//)\\s*typos:on", ] [default.extend-words] diff --git a/apps/app-frontend/package.json b/apps/app-frontend/package.json index 18ff367db0..abe6e55c0f 100644 --- a/apps/app-frontend/package.json +++ b/apps/app-frontend/package.json @@ -14,6 +14,7 @@ "test": "vue-tsc --noEmit" }, "dependencies": { + "@dnd-kit/vue": "^0.5.0", "@modrinth/api-client": "workspace:^", "@modrinth/assets": "workspace:*", "@modrinth/ui": "workspace:*", @@ -37,7 +38,6 @@ "intl-messageformat": "^10.7.7", "ofetch": "^1.3.4", "overlayscrollbars": "^2.15.1", - "pinia": "^3.0.0", "posthog-js": "^1.158.2", "three": "^0.172.0", "vite-svg-loader": "^5.1.0", diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index 3db155776b..33afd4d216 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -13,11 +13,10 @@ import { ChevronLeftIcon, ChevronRightIcon, CompassIcon, - HomeIcon, - LibraryIcon, LogInIcon, LogOutIcon, NewspaperIcon, + PlayIcon, PlusIcon, RefreshCwIcon, RightArrowIcon, @@ -57,7 +56,7 @@ import { import { renderString } from '@modrinth/utils' import { useQuery, useQueryClient } from '@tanstack/vue-query' import { getVersion } from '@tauri-apps/api/app' -import { invoke } from '@tauri-apps/api/core' +import { convertFileSrc, invoke } from '@tauri-apps/api/core' import { getCurrentWindow } from '@tauri-apps/api/window' import { fetch as tauriFetch } from '@tauri-apps/plugin-http' import { openUrl } from '@tauri-apps/plugin-opener' @@ -74,6 +73,7 @@ import FriendsList from '@/components/ui/friends/FriendsList.vue' import HostingUpdateRequired from '@/components/ui/HostingUpdateRequired.vue' import AddServerToInstanceModal from '@/components/ui/install_flow/AddServerToInstanceModal.vue' import UnknownPackWarningModal from '@/components/ui/install_flow/UnknownPackWarningModal.vue' +import IconEditorModal from '@/components/ui/instance_settings/icon-editor-modal/index.vue' import MinecraftAuthErrorModal from '@/components/ui/minecraft-auth-error-modal/MinecraftAuthErrorModal.vue' import MinecraftRequiredModal from '@/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue' import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue' @@ -82,6 +82,9 @@ import ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyIn import ModrinthAccountRequiredModal from '@/components/ui/modal/ModrinthAccountRequiredModal.vue' import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue' import NavButton from '@/components/ui/NavButton.vue' +import NewIconEditorNotification from '@/components/ui/new-icon-editor-notification/index.vue' +import { shouldShowNewIconEditorNotification } from '@/components/ui/new-icon-editor-notification/show-notification' +import OnboardingChecklist from '@/components/ui/onboarding-checklist/index.vue' import PrideFundraiserBanner from '@/components/ui/PrideFundraiserBanner.vue' import PromotionWrapper from '@/components/ui/PromotionWrapper.vue' import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue' @@ -91,6 +94,9 @@ import SurveyPopup from '@/components/ui/SurveyPopup.vue' import WindowControls from '@/components/ui/WindowControls.vue' import { useCheckDisableMouseover } from '@/composables/macCssFix.js' import { useAppEvent } from '@/composables/use-app-event' +import { useAppSettings } from '@/composables/use-app-settings.ts' +import { useError } from '@/composables/use-error.js' +import { useTheme } from '@/composables/use-theme.ts' import { config } from '@/config' import { hide_ads_window, @@ -144,8 +150,7 @@ import { setupProviders } from '@/providers/setup' import { setupAppEventsProvider } from '@/providers/setup/app-events' import { setupAuthProvider } from '@/providers/setup/auth' import { setupLoadingStateProvider } from '@/providers/setup/loading-state' -import { useError } from '@/store/error.js' -import { useTheming } from '@/store/state' +import { setupAppUserPreferencesProvider } from '@/providers/setup/user-preferences.ts' import { appMessages } from '@/utils/app-messages' import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer' @@ -154,7 +159,8 @@ import { AppNotificationManager } from './providers/app-notifications' import { AppPopupNotificationManager } from './providers/app-popup-notifications' import { appSettingsModalOpenProfileKey } from './providers/app-settings-modal' -const themeStore = useTheming() +const appSettings = useAppSettings() +const appTheme = useTheme() const router = useRouter() const route = useRoute() const { channel: appEventChannel, events: appEvents } = setupAppEventsProvider() @@ -198,7 +204,7 @@ const credentials = ref() let credentialsRefreshId = 0 const sidebarToggled = ref(true) watch( - () => themeStore.toggleSidebar, + () => appSettings.toggleSidebar, (toggleSidebar) => { sidebarToggled.value = !toggleSidebar }, @@ -218,7 +224,7 @@ const hostingUpdateRequired = computed( appUpdateState.updatesEnabled.value, ) const prideFundraiserEnabled = computed( - () => themeStore.getFeatureFlag('pride_fundraiser') && Date.now() < PRIDE_FUNDRAISER_END_DATE, + () => appSettings.getFeatureFlag('pride_fundraiser') && Date.now() < PRIDE_FUNDRAISER_END_DATE, ) const hostingIntercomIdentityKey = computed(() => { const rawServerId = route.params.id @@ -248,7 +254,7 @@ useAppEvent( 'warning', (event) => addNotification({ - title: 'Warning', + title: formatMessage(messages.warning), text: event.message, type: 'warning', }), @@ -321,30 +327,79 @@ providePageContext({ intercomBubble: hostingIntercom.intercomBubble, featureFlags: { serverRamAsBytesAlwaysOn: computed(() => - themeStore.getFeatureFlag('server_ram_as_bytes_always_on'), + appSettings.getFeatureFlag('server_ram_as_bytes_always_on'), ), }, openExternalUrl: (url) => void openUrl(url), }) provideModalBehavior({ - noblur: computed(() => !themeStore.advancedRendering), + noblur: computed(() => !appTheme.advancedRendering), onShow: () => take_ads_window_hold(), onHide: () => release_ads_window_hold(), }) +const creationIconEditorModal = ref(null) +const creationGeneratedIcon = ref(null) +const creationIconTarget = ref('creation-flow') + const { installationModal, unknownPackWarningModal, fetchExistingInstanceNames, handleCreate, handleBrowseModpacks, - searchModpacks, - getProjectVersions, + searchProjects, getLoaderManifest, setModpackAlreadyInstalledModal, handleModpackDuplicateCreateAnyway, handleModpackDuplicateGoToInstance, -} = setupProviders(tauriApiClient, notificationManager, popupNotificationManager) + onboardingChecklist, +} = setupProviders( + tauriApiClient, + notificationManager, + popupNotificationManager, + appEvents, + (iconPath) => + creationGeneratedIcon.value?.path === iconPath ? creationGeneratedIcon.value.config : null, +) +const { hasLoggedIntoMinecraft, hasLoggedIntoModrinth, showChecklist } = onboardingChecklist +const showFriendsList = computed(() => !showChecklist.value || hasLoggedIntoModrinth.value) + +async function randomizeCreationIcon() { + const generated = await creationIconEditorModal.value?.randomizeAndSave() + if (!generated) return null + + creationGeneratedIcon.value = { path: generated.iconPath, config: generated.config } + return { + path: generated.iconPath, + previewUrl: convertFileSrc(generated.iconPath), + } +} + +function customizeCreationIcon() { + creationIconTarget.value = 'creation-flow' + creationIconEditorModal.value?.show() +} + +function customizeContentInstallIcon() { + creationIconTarget.value = 'content-install' + creationIconEditorModal.value?.show() +} + +function onCreationIconSaved(iconPath, config) { + creationGeneratedIcon.value = { path: iconPath, config } + if (creationIconTarget.value === 'content-install') { + modInstallModal.value?.setIcon(iconPath, convertFileSrc(iconPath)) + return + } + + const context = installationModal.value?.ctx + if (!context) return + + context.instanceIcon.value = null + context.instanceIconUrl.value = convertFileSrc(iconPath) + context.instanceIconPath.value = iconPath +} const news = ref([]) const displayedServerInviteNotifications = new Set() @@ -360,7 +415,6 @@ window.addEventListener('online', () => { offline.value = false }) -const showOnboarding = ref(false) const nativeDecorations = ref(false) const os = ref('') @@ -425,6 +479,15 @@ const { formatMessage } = useVIntl() const formatBytes = useFormatBytes() const messages = defineMessages({ + warning: { id: 'app.notification.warning', defaultMessage: 'Warning' }, + moreOptions: { id: 'app.navigation.more-options', defaultMessage: 'More options' }, + goBack: { id: 'app.navigation.go-back', defaultMessage: 'Go back' }, + goForward: { id: 'app.navigation.go-forward', defaultMessage: 'Go forward' }, + nextImage: { id: 'app.navigation.next-image', defaultMessage: 'Next image' }, + updateDownloadMissingVersion: { + id: 'app.update.download-error.missing-version', + defaultMessage: 'Failed to download update: no version available', + }, updateInstalledToastTitle: { id: 'app.update.complete-toast.title', defaultMessage: 'Version {version} was successfully installed!', @@ -467,10 +530,6 @@ const messages = defineMessages({ id: 'app.nav.home', defaultMessage: 'Home', }, - library: { - id: 'app.nav.library', - defaultMessage: 'Library', - }, modrinthHosting: { id: 'app.nav.modrinth-hosting', defaultMessage: 'Modrinth Hosting', @@ -530,6 +589,7 @@ function handleAdsConsentRequired(required) { } const notification = addPopupNotification({ + contentType: 'standard', title: formatMessage(messages.adsConsentTitle), text: formatMessage(messages.adsConsentBody), type: 'info', @@ -562,17 +622,26 @@ function handleAdsConsentRequired(required) { } async function setupApp() { + await onboardingChecklist.initialize() + + if (shouldShowNewIconEditorNotification(showChecklist.value)) { + addPopupNotification({ + contentType: 'custom', + component: NewIconEditorNotification, + autoCloseMs: null, + }) + } + const { native_decorations, theme, locale, telemetry, - collapsed_navigation, hide_nametag_skins_page, advanced_rendering, - onboarded, - default_page, toggle_sidebar, + sync_theme_across_devices, + sync_behavior_across_devices, developer_mode, feature_flags, pending_update_toast_for_version, @@ -583,26 +652,21 @@ async function setupApp() { i18n.global.locale.value = locale } - if (default_page === 'Library') { - await router.push('/library') - } - os.value = await getOS() const dev = await isDev() isDevEnvironment.value = dev const version = await getVersion() - showOnboarding.value = !onboarded - nativeDecorations.value = native_decorations if (os.value !== 'MacOS') await getCurrentWindow().setDecorations(native_decorations) - themeStore.setThemeState(theme) - themeStore.collapsedNavigation = collapsed_navigation - themeStore.advancedRendering = advanced_rendering - themeStore.hideNametagSkinsPage = hide_nametag_skins_page - themeStore.toggleSidebar = toggle_sidebar - themeStore.devMode = developer_mode - themeStore.featureFlags = feature_flags + appTheme.preferred = theme + appTheme.advancedRendering = advanced_rendering + appTheme.syncAcrossDevices = sync_theme_across_devices + appSettings.syncBehaviorAcrossDevices = sync_behavior_across_devices + appSettings.hideNametagSkinsPage = hide_nametag_skins_page + appSettings.toggleSidebar = toggle_sidebar + appSettings.devMode = developer_mode + Object.assign(appSettings.featureFlags, feature_flags) stateInitialized.value = true isMaximized.value = await getCurrentWindow().isMaximized() @@ -614,7 +678,7 @@ async function setupApp() { if (telemetry) { initAnalytics() if (dev) debugAnalytics() - trackEvent('Launched', { version, dev, onboarded }) + trackEvent('Launched', { version, dev }) } if (!dev) document.addEventListener('contextmenu', (event) => event.preventDefault()) @@ -818,6 +882,7 @@ const { projectInfo: contentInstallProjectInfo, handleInstallToInstance, handleCreateAndInstall, + prepareNewInstance, handleNavigate: handleContentInstallNavigate, handleCancel: handleContentInstallCancel, setContentInstallModal, @@ -837,6 +902,35 @@ const { handleIncompatibilityWarningCancel: handleContentInstallIncompatibilityWarningCancel, } = contentInstall +async function prepareCreationProjectInstall(projectId, projectType) { + if (projectType === 'modpack') { + await contentInstall.install( + projectId, + null, + null, + 'CreationModalProject', + undefined, + (instanceId) => void router.push(`/instance/${encodeURIComponent(instanceId)}`), + ) + return null + } + + await prepareNewInstance(projectId) + const info = contentInstallProjectInfo.value + if (!info) throw new Error(`Project information is unavailable: '${projectId}'`) + + return { + projectId, + title: info.title, + iconUrl: info.iconUrl, + link: info.link, + owner: info.owner, + compatibleLoaders: [...contentInstallLoaders.value], + gameVersions: [...contentInstallGameVersions.value], + releaseGameVersions: new Set(contentInstallReleaseGameVersions.value), + } +} + const serverInstall = createServerInstall({ router, handleError, @@ -870,7 +964,7 @@ watch(incompatibilityWarningModal, (modal) => { } }) -setupAuthProvider(credentials, async (_redirectPath, flow, options) => { +const authProvider = setupAuthProvider(credentials, async (_redirectPath, flow, options) => { if (options?.showModal === false) { await signIn(flow) } else { @@ -878,6 +972,80 @@ setupAuthProvider(credentials, async (_redirectPath, flow, options) => { } }) +const userPreferences = setupAppUserPreferencesProvider(authProvider, notificationManager) +let userPreferencesSync = Promise.resolve() + +watch( + [userPreferences.preferences, stateInitialized], + ([preferences, initialized]) => { + if (!preferences || !initialized) return + + userPreferencesSync = userPreferencesSync + .then(async () => { + const settings = await getSettings() + const selectedTheme = preferences.appearance.auto ? 'system' : preferences.appearance.theme + const locale = preferences.localization.locale + const behavior = preferences.behavior + let settingsChanged = false + + if (appTheme.syncAcrossDevices && appTheme.preferred !== selectedTheme) { + appTheme.preferred = selectedTheme + } + if (i18n.global.locale.value !== locale) { + i18n.global.locale.value = locale + } + + if (appTheme.syncAcrossDevices && settings.theme !== selectedTheme) { + settings.theme = selectedTheme + settingsChanged = true + } + if (settings.locale !== locale) { + settings.locale = locale + settingsChanged = true + } + + if (behavior && appSettings.syncBehaviorAcrossDevices) { + const behaviorFeatureFlags = { + worlds_in_home: behavior.show_jump_in, + compact_instance_cards: behavior.compact_instance_cards, + show_instance_play_time: behavior.show_play_time, + skip_unknown_pack_warning: !behavior.warn_on_unknown_modpacks, + skip_non_essential_warnings: behavior.skip_non_essential_warnings, + } + + appSettings.toggleSidebar = behavior.hide_right_sidebar + appSettings.hideNametagSkinsPage = behavior.hide_nametag + Object.assign(appSettings.featureFlags, behaviorFeatureFlags) + + if (settings.hide_on_process_start !== behavior.minimize_app) { + settings.hide_on_process_start = behavior.minimize_app + settingsChanged = true + } + if (settings.toggle_sidebar !== behavior.hide_right_sidebar) { + settings.toggle_sidebar = behavior.hide_right_sidebar + settingsChanged = true + } + if (settings.hide_nametag_skins_page !== behavior.hide_nametag) { + settings.hide_nametag_skins_page = behavior.hide_nametag + settingsChanged = true + } + for (const [flag, value] of Object.entries(behaviorFeatureFlags)) { + if (settings.feature_flags[flag] !== value) { + settings.feature_flags[flag] = value + settingsChanged = true + } + } + } + + if (settingsChanged) { + await setSettings(settings) + } + }) + .catch(handleError) + }, + { immediate: true }, +) + async function validateSession(sessionToken) { try { const response = await tauriFetch(`${config.labrinthBaseUrl}/v2/user`, { @@ -1090,17 +1258,16 @@ async function handleLiveNotification(notification) { if (generation !== liveNotificationGeneration) return const popupNotification = addPopupNotification({ + contentType: 'toast', title: serverName, + type: 'server-invite', + actorName: invitedBy?.username ?? null, + actorAvatarUrl: invitedBy?.avatar_url ?? null, + entityName: serverName, autoCloseMs: null, - toast: { - type: 'server-invite', - actorName: invitedBy?.username ?? null, - actorAvatarUrl: invitedBy?.avatar_url ?? null, - entityName: serverName, - onAccept: () => acceptServerInviteNotification(notification), - onDecline: () => declineServerInviteNotification(notification), - onOpenActor: () => openServerInviteInviterProfile(invitedBy?.username ?? null), - }, + onAccept: () => acceptServerInviteNotification(notification), + onDecline: () => declineServerInviteNotification(notification), + onOpenActor: () => openServerInviteInviterProfile(invitedBy?.username ?? null), }) serverInvitePopupNotificationIds.add(popupNotification.id) } @@ -1277,6 +1444,7 @@ function showDelayedUpdatePopup() { if (metered.value && !finishedDownloading.value) { addPopupNotification({ + contentType: 'standard', title: formatMessage(updatePopupMessages.updateAvailable), text: formatMessage(updatePopupMessages.meteredBody, { version: update.version }), type: 'info', @@ -1298,6 +1466,7 @@ function showDelayedUpdatePopup() { }) } else if (finishedDownloading.value) { addPopupNotification({ + contentType: 'standard', title: formatMessage(updatePopupMessages.downloadComplete), text: formatMessage(updatePopupMessages.downloadedBody, { version: update.version, @@ -1396,6 +1565,7 @@ async function checkLinuxUpdates() { const nextPopupTime = getNextAppUpdatePopupTime(latestVersion) if (nextPopupTime !== null && Date.now() >= nextPopupTime) { addPopupNotification({ + contentType: 'standard', title: formatMessage(updatePopupMessages.updateAvailable), text: formatMessage(updatePopupMessages.linuxBody, { version: latestVersion }), type: 'info', @@ -1415,7 +1585,7 @@ async function downloadAvailableUpdate() { async function downloadUpdate(versionToDownload) { if (!versionToDownload) { - handleError(`Failed to download update: no version available`) + handleError(formatMessage(messages.updateDownloadMissingVersion)) return } @@ -1432,9 +1602,8 @@ async function downloadUpdate(versionToDownload) { .then(() => { downloading.value = false finishedDownloading.value = true - unlistenUpdateDownload?.().then(() => { - unlistenUpdateDownload = null - }) + unlistenUpdateDownload?.() + unlistenUpdateDownload = null console.log('Finished downloading!') markAppUpdateActionable(versionToDownload.version, 'downloaded') scheduleDelayedUpdatePopup() @@ -1551,7 +1720,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
+
- - + + - - -
@@ -1773,7 +1945,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
{{ route.fullPath }} @@ -1808,7 +1980,9 @@ provideAppUpdateDownloadProgress(appUpdateDownload) @@ -1823,9 +1997,17 @@ provideAppUpdateDownloadProgress(appUpdateDownload) :class="{ 'pb-12': !hasPlus }" data-overlayscrollbars-initialize > +