From 02e10be4db91e354086493db4ceac502a9055683 Mon Sep 17 00:00:00 2001 From: Prospector <6166773+Prospector@users.noreply.github.com> Date: Mon, 11 May 2026 19:57:39 -0700 Subject: [PATCH] fix: open modrinth project links in the app (#6072) --- apps/app-frontend/src/App.vue | 31 ++++++- .../app-frontend/src/helpers/project-links.ts | 83 +++++++++++++++++++ .../src/modules/labrinth/projects/v2.ts | 17 ++++ .../api-client/src/modules/labrinth/types.ts | 4 + 4 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 apps/app-frontend/src/helpers/project-links.ts diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index 232305c09..b8ff688b7 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -2,6 +2,7 @@ import { Intercom, shutdown as shutdownIntercom } from '@intercom/messenger-js-sdk' import { AuthFeature, + ModrinthApiError, NodeAuthFeature, nodeAuthState, PanelVersionFeature, @@ -98,6 +99,7 @@ import { command_listener, warning_listener } from '@/helpers/events.js' import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts' import { create_profile_and_install_from_file } from '@/helpers/pack' import { list } from '@/helpers/profile.js' +import { mergeUrlQuery, parseModrinthLink } from '@/helpers/project-links.ts' import { get as getSettings, set as setSettings } from '@/helpers/settings.ts' import { get_opening_command, initialize_state } from '@/helpers/state' import { @@ -1028,6 +1030,28 @@ async function installUpdate() { }, 250) } +async function openModrinthProjectLinkInApp(parsed) { + const { slug, pathSuffix, url } = parsed + const loadToken = loading.begin() + try { + const { id } = await tauriApiClient.labrinth.projects_v2.check(slug) + const query = mergeUrlQuery(route.query, url) + await router.push({ + path: `/project/${id}${pathSuffix}`, + query, + hash: url.hash || undefined, + }) + } catch (err) { + if (err instanceof ModrinthApiError && err.statusCode === 404) { + openUrl(url.href) + } else { + handleError(err) + } + } finally { + loading.end(loadToken) + } +} + function handleClick(e) { let target = e.target while (target != null) { @@ -1040,7 +1064,12 @@ function handleClick(e) { !target.href.startsWith('https://tauri.localhost') && !target.href.startsWith('http://tauri.localhost') ) { - openUrl(target.href) + const parsed = parseModrinthLink(target.href) + if (parsed) { + void openModrinthProjectLinkInApp(parsed) + } else { + openUrl(target.href) + } } e.preventDefault() break diff --git a/apps/app-frontend/src/helpers/project-links.ts b/apps/app-frontend/src/helpers/project-links.ts new file mode 100644 index 000000000..3ce0c0c45 --- /dev/null +++ b/apps/app-frontend/src/helpers/project-links.ts @@ -0,0 +1,83 @@ +import type { LocationQuery, LocationQueryRaw } from 'vue-router' + +const MODRINTH_HOSTNAMES = new Set(['modrinth.com', 'www.modrinth.com']) + +const SUPPORTED_PROJECT_TYPES = new Set([ + 'mod', + 'modpack', + 'resourcepack', + 'datapack', + 'plugin', + 'shader', + 'server', + 'project', +]) + +export function parseModrinthLink( + href: string, +): { slug: string; pathSuffix: string; url: URL } | null { + let url: URL + try { + url = new URL(href) + } catch { + return null + } + + if (!MODRINTH_HOSTNAMES.has(url.hostname.toLowerCase())) { + return null + } + + const segments = url.pathname.split('/').filter((p) => p.length > 0) + if (segments.length < 2) { + return null + } + + if (SUPPORTED_PROJECT_TYPES.has(segments[0].toLowerCase())) { + const slug = segments[1] + if (!slug) { + return null + } + + const rest: string[] = segments.slice(2) + const pathSuffix = toValidAppSubpath(rest) + if (pathSuffix === null) { + return null + } + + return { slug, pathSuffix, url } + } else { + return null + } +} + +const SUPPORTED_SUBPATHS = ['versions', 'gallery'] + +function toValidAppSubpath(rest: string[]): string | null { + if (rest.length === 0) { + return '' + } + + const subroute = rest[0].toLowerCase() + if (rest.length === 1 && SUPPORTED_SUBPATHS.includes(subroute)) { + return `/${subroute}` + } + + if (rest.length === 2 && subroute === 'version') { + return `/version/${rest[1]}` + } + + return null +} + +export function mergeUrlQuery(routeQuery: LocationQuery, linkUrl: URL): LocationQueryRaw { + const newQuery: LocationQueryRaw = { ...routeQuery } + const keys = new Set() + linkUrl.searchParams.forEach((_value, key) => { + keys.add(key) + }) + for (const key of keys) { + const values = linkUrl.searchParams.getAll(key) + newQuery[key] = values.length === 1 ? values[0] : values + } + return newQuery +} diff --git a/packages/api-client/src/modules/labrinth/projects/v2.ts b/packages/api-client/src/modules/labrinth/projects/v2.ts index 4aa75f4b0..62197d23c 100644 --- a/packages/api-client/src/modules/labrinth/projects/v2.ts +++ b/packages/api-client/src/modules/labrinth/projects/v2.ts @@ -26,6 +26,23 @@ export class LabrinthProjectsV2Module extends AbstractModule { }) } + /** + * Check that a project slug or ID exists and return its canonical project ID. + * + * @param idOrSlug - Project ID or slug (e.g. `sodium` or `AANobbMI`) + */ + public async check(idOrSlug: string): Promise { + const encoded = encodeURIComponent(idOrSlug) + return this.client.request( + `/project/${encoded}/check`, + { + api: 'labrinth', + version: 2, + method: 'GET', + }, + ) + } + /** * Get multiple projects by IDs * diff --git a/packages/api-client/src/modules/labrinth/types.ts b/packages/api-client/src/modules/labrinth/types.ts index aa44c1922..098e46d19 100644 --- a/packages/api-client/src/modules/labrinth/types.ts +++ b/packages/api-client/src/modules/labrinth/types.ts @@ -468,6 +468,10 @@ export namespace Labrinth { monetization_status: MonetizationStatus } + export type ProjectCheckResponse = { + id: string + } + export type SearchResultHit = { project_id: string project_type: ProjectType