mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-05-11 04:23:50 -04:00
fix: make PWA share target functional on Android Chrome (#7468)
Co-authored-by: Zdenek <tvuj-email@example.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,8 +3,11 @@ export default defineNuxtRouteMiddleware((to) => {
|
|||||||
const { user } = useMealieAuth();
|
const { user } = useMealieAuth();
|
||||||
const groupSlug = user.value?.groupSlug;
|
const groupSlug = user.value?.groupSlug;
|
||||||
if (!groupSlug) {
|
if (!groupSlug) {
|
||||||
return navigateTo("/login", { redirectCode: 301 });
|
// Preserve the full path (including recipe_import_url query param) so the
|
||||||
|
// login page can redirect back here after successful authentication.
|
||||||
|
const redirect = encodeURIComponent(to.fullPath);
|
||||||
|
return navigateTo(`/login?redirect=${redirect}`, { redirectCode: 302 });
|
||||||
}
|
}
|
||||||
return navigateTo(`/g/${groupSlug}${to.fullPath}`, { redirectCode: 301 });
|
return navigateTo(`/g/${groupSlug}${to.fullPath}`, { redirectCode: 302 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -198,15 +198,32 @@ const recipeUrl = computed({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
get() {
|
get() {
|
||||||
return route.query.recipe_import_url as string | null;
|
// Prefer the 'url' share field (recipe_import_url, populated by Chrome when
|
||||||
|
// sharing a page URL). Fall back to the 'text' share field (recipe_import_text)
|
||||||
|
// for apps that share URLs as plain text, but only when the text value is
|
||||||
|
// actually a valid http/https URL — shared text can be arbitrary.
|
||||||
|
const urlFromField = route.query.recipe_import_url as string | null;
|
||||||
|
if (urlFromField) {
|
||||||
|
return urlFromField;
|
||||||
|
}
|
||||||
|
const textFromField = route.query.recipe_import_text as string | null;
|
||||||
|
if (textFromField) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(textFromField);
|
||||||
|
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
|
||||||
|
return textFromField;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* not a URL, ignore */ }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (recipeUrl.value && recipeUrl.value.includes("https")) {
|
if (recipeUrl.value) {
|
||||||
// Check if we have a query params for using keywords as tags or staying in edit mode.
|
// Apply legacy query params for older automations such as the Bookmarklet.
|
||||||
// We don't use these in the app anymore, but older automations such as Bookmarklet might still use them,
|
// These are no longer used by the app itself but are easy to keep supporting.
|
||||||
// and they're easy enough to support.
|
|
||||||
const importKeywordsAsTagsParam = route.query.use_keywords;
|
const importKeywordsAsTagsParam = route.query.use_keywords;
|
||||||
if (importKeywordsAsTagsParam === "1") {
|
if (importKeywordsAsTagsParam === "1") {
|
||||||
importKeywordsAsTags.value = true;
|
importKeywordsAsTags.value = true;
|
||||||
@@ -223,8 +240,9 @@ onMounted(() => {
|
|||||||
stayInEditMode.value = false;
|
stayInEditMode.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
createByUrl(recipeUrl.value, importKeywordsAsTags.value, false);
|
// The URL is pre-filled via the recipeUrl computed property.
|
||||||
return;
|
// Do not auto-submit: the user should review the import options and
|
||||||
|
// confirm by clicking the submit button.
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -211,7 +211,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useDark, whenever } from "@vueuse/core";
|
import { useDark, useSessionStorage, whenever } from "@vueuse/core";
|
||||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
import { usePasswordField } from "~/composables/use-passwords";
|
import { usePasswordField } from "~/composables/use-passwords";
|
||||||
import { alert } from "~/composables/use-toast";
|
import { alert } from "~/composables/use-toast";
|
||||||
@@ -225,6 +225,7 @@ definePageMeta({
|
|||||||
const isDark = useDark();
|
const isDark = useDark();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const auth = useMealieAuth();
|
const auth = useMealieAuth();
|
||||||
const { $appInfo, $axios } = useNuxtApp();
|
const { $appInfo, $axios } = useNuxtApp();
|
||||||
@@ -235,6 +236,9 @@ const isFirstLogin = ref(false);
|
|||||||
const activityPreferences = useUserActivityPreferences();
|
const activityPreferences = useUserActivityPreferences();
|
||||||
const { getDefaultActivityRoute } = useDefaultActivity();
|
const { getDefaultActivityRoute } = useDefaultActivity();
|
||||||
|
|
||||||
|
// Survives the page reload that happens during OIDC redirect
|
||||||
|
const pendingShareRedirect = useSessionStorage<string | null>("pwa_share_redirect", null);
|
||||||
|
|
||||||
useSeoMeta({
|
useSeoMeta({
|
||||||
title: i18n.t("user.login"),
|
title: i18n.t("user.login"),
|
||||||
});
|
});
|
||||||
@@ -259,14 +263,29 @@ useAsyncData(useAsyncKey(), async () => {
|
|||||||
whenever(
|
whenever(
|
||||||
() => loggedIn.value && groupSlug.value,
|
() => loggedIn.value && groupSlug.value,
|
||||||
() => {
|
() => {
|
||||||
|
// First-login setup always takes priority
|
||||||
|
if (!isDemo.value && isFirstLogin.value && auth.user.value?.admin) {
|
||||||
|
router.push("/admin/setup");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// After login, honour a pending PWA share redirect.
|
||||||
|
// The redirect param can arrive via the URL query string (password login)
|
||||||
|
// or via sessionStorage (OIDC login, where the OIDC provider reload clears
|
||||||
|
// the query string).
|
||||||
|
const redirectFromQuery = route.query.redirect as string | undefined;
|
||||||
|
const redirectTarget = redirectFromQuery ?? pendingShareRedirect.value;
|
||||||
|
if (redirectTarget && redirectTarget.startsWith("/")) {
|
||||||
|
pendingShareRedirect.value = null;
|
||||||
|
router.push(redirectTarget);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const defaultActivityRoute = getDefaultActivityRoute(
|
const defaultActivityRoute = getDefaultActivityRoute(
|
||||||
activityPreferences.value.defaultActivity,
|
activityPreferences.value.defaultActivity,
|
||||||
groupSlug.value,
|
groupSlug.value,
|
||||||
);
|
);
|
||||||
if (!isDemo.value && isFirstLogin.value && auth.user.value?.admin) {
|
if (defaultActivityRoute) {
|
||||||
router.push("/admin/setup");
|
|
||||||
}
|
|
||||||
else if (defaultActivityRoute) {
|
|
||||||
router.push(defaultActivityRoute);
|
router.push(defaultActivityRoute);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
@@ -316,6 +335,13 @@ async function oidcAuthenticate(callback = false) {
|
|||||||
oidcLoggingIn.value = false;
|
oidcLoggingIn.value = false;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
|
// Save any pending PWA share redirect before leaving for the OIDC provider.
|
||||||
|
// The OIDC callback reloads the page, which clears the query string, so we
|
||||||
|
// persist the target in sessionStorage and restore it after login.
|
||||||
|
const redirectTarget = route.query.redirect as string | undefined;
|
||||||
|
if (redirectTarget && redirectTarget.startsWith("/")) {
|
||||||
|
pendingShareRedirect.value = redirectTarget;
|
||||||
|
}
|
||||||
navigateTo("/api/auth/oauth", { external: true }); // start the redirect process
|
navigateTo("/api/auth/oauth", { external: true }); // start the redirect process
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,13 @@ def serve_manifest():
|
|||||||
"action": "/r/create/url",
|
"action": "/r/create/url",
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"enctype": "application/x-www-form-urlencoded",
|
"enctype": "application/x-www-form-urlencoded",
|
||||||
"params": {"text": "recipe_import_url"},
|
"params": {
|
||||||
|
# 'url' is the field Chrome Android populates when sharing a page URL
|
||||||
|
"url": "recipe_import_url",
|
||||||
|
# 'text' is used by apps that share URLs as plain text; mapped to a
|
||||||
|
# separate param so the page can fall back to it when 'url' is absent
|
||||||
|
"text": "recipe_import_text",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"icons": [
|
"icons": [
|
||||||
{"src": "/icons/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any"},
|
{"src": "/icons/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any"},
|
||||||
|
|||||||
Reference in New Issue
Block a user