diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4282131..b0082b9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,11 @@
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
+## 0.67.0 — 2026-07-21 09:30
+
+### Added
+- Epicure now registers as an OS share target (Chromium/Android, once installed): share a recipe link from another app's share sheet and it opens straight into the URL-import flow, pre-filled and auto-imported. Safari has no equivalent API — Chromium/Android only, same restriction as the install-prompt banner.
+
## 0.66.0 — 2026-07-21 09:00
### Added
diff --git a/FEATURE_AUDIT.md b/FEATURE_AUDIT.md
index a289ad7..33a57c9 100644
--- a/FEATURE_AUDIT.md
+++ b/FEATURE_AUDIT.md
@@ -130,7 +130,7 @@ Status legend: **Exists** (fully working) · **Partial** (works but with a real
| Push (client + server) | Exists end-to-end | Real subscribe/unsubscribe with correct state sync, VAPID, multiple triggers wired | `apps/web/lib/push.ts` |
| Voice control | **Exists** | Web Speech API in cooking mode (this was previously assumed missing — it isn't) | `apps/web/components/cooking-mode/cooking-mode.tsx` |
| Barcode scanning | **Exists** (pantry only) | `BarcodeDetector` isn't used, but a manual-entry-triggered Open Food Facts API lookup covers the same use case | `apps/web/app/api/v1/pantry/scan/barcode` |
-| OS Share Target (share a URL into Epicure from another app) | **Missing** | No `share_target` in the manifest | — |
+| OS Share Target (share a URL into Epicure from another app) | Exists (Chromium/Android only) | Manifest `share_target` → `/recipes?title&text&url`; extracts the shared link from either `url` or a URL substring in `text` (senders vary), auto-opens the existing import-from-URL dialog pre-filled and auto-imports. Safari has no Share Target API at all — same Apple-restriction story as install prompts. | `apps/web/app/manifest.ts`, `apps/web/app/(app)/recipes/page.tsx`, `apps/web/components/recipe/url-import-dialog.tsx` |
| Home-screen widgets, Siri/Assistant shortcuts | **Missing**, expected — N/A for a pure PWA without a native shell | — |
| Native mobile app (React Native/Capacitor/Expo) | **Missing** | Confirmed PWA-only, no native project anywhere in the monorepo | — |
@@ -144,7 +144,7 @@ Ranked roughly by likely value:
2. **Recipe video** — no support at all.
3. **Nutrition trend/history view** — diary is single-day only, no multi-day chart.
4. **USDA/nutrition-database lookup** — all nutrition numbers are AI-estimated; barcode scan only gets product name, not nutrition facts.
-5. **OS Share Target** — can't share a recipe link into Epicure from another app's share sheet.
+5. ~~OS Share Target~~ — closed 2026-07-21 (Chromium/Android only, no Safari equivalent exists).
6. ~~Push click-through handling~~ — closed 2026-07-21 (`push`/`notificationclick` listeners added to `sw.js`).
7. **Anonymous public link for meal plans** — shopping lists have this, meal plans don't.
8. **Grocery delivery/live pricing** beyond the Instacart stub (which needs a partnership agreement to go live).
diff --git a/apps/web/app/(app)/recipes/page.tsx b/apps/web/app/(app)/recipes/page.tsx
index ad8c549..ec69919 100644
--- a/apps/web/app/(app)/recipes/page.tsx
+++ b/apps/web/app/(app)/recipes/page.tsx
@@ -24,8 +24,25 @@ type SearchParams = Promise<{
page?: string;
batchCook?: string;
recipeType?: string;
+ // OS Share Target params (manifest.ts's share_target) — a browser share
+ // sheet navigates here with these appended.
+ url?: string;
+ text?: string;
+ title?: string;
}>;
+const URL_PATTERN = /https?:\/\/\S+/;
+
+/** Web Share Target's GET params vary by sender: some apps put the shared
+ * link in `url`, others (notably many Android apps) put it inside `text`
+ * alongside other text. Prefer `url`, fall back to extracting the first
+ * http(s) URL out of `text`. */
+function extractSharedUrl(params: { url?: string; text?: string }): string | undefined {
+ if (params.url && URL_PATTERN.test(params.url)) return params.url;
+ const match = params.text?.match(URL_PATTERN);
+ return match?.[0];
+}
+
const SORT_MAP = {
updated_desc: desc(recipes.updatedAt),
updated_asc: asc(recipes.updatedAt),
@@ -43,7 +60,8 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear
const m = getMessages((session.user as { locale?: string }).locale);
const featurePrefs = await getFeaturePrefs(session.user.id);
- const { q, sort, visibility, difficulty, tag, page: pageParam, batchCook, recipeType } = await searchParams;
+ const { q, sort, visibility, difficulty, tag, page: pageParam, batchCook, recipeType, url, text } = await searchParams;
+ const sharedUrl = extractSharedUrl({ url, text });
const query = (q ?? "").trim().slice(0, 200);
const sortKey: SortKey = (sort && sort in SORT_MAP ? sort : "updated_desc") as SortKey;
const tagFilter = tag?.trim().slice(0, 50) || undefined;
@@ -140,6 +158,7 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear
initialTag={tagFilter ?? ""}
initialBatchCook={batchCookFilter ?? ""}
initialRecipeType={recipeTypeFilter ?? ""}
+ sharedUrl={sharedUrl}
/>
diff --git a/apps/web/app/manifest.ts b/apps/web/app/manifest.ts
index 808d644..0aec94a 100644
--- a/apps/web/app/manifest.ts
+++ b/apps/web/app/manifest.ts
@@ -15,5 +15,18 @@ export default function manifest(): MetadataRoute.Manifest {
{ src: "/icon-512.svg", sizes: "512x512", type: "image/svg+xml", purpose: "any" },
{ src: "/icon-512.svg", sizes: "512x512", type: "image/svg+xml", purpose: "maskable" },
],
+ // Registers Epicure as a share target in the OS share sheet (Chromium
+ // on Android/desktop once installed — Safari has no equivalent API).
+ // Sharing a recipe link from another app opens /recipes with these
+ // query params, which auto-opens the URL-import dialog pre-filled.
+ share_target: {
+ action: "/recipes",
+ method: "GET",
+ params: {
+ title: "title",
+ text: "text",
+ url: "url",
+ },
+ },
};
}
diff --git a/apps/web/components/recipe/recipes-header.tsx b/apps/web/components/recipe/recipes-header.tsx
index 3630e8a..a04fc26 100644
--- a/apps/web/components/recipe/recipes-header.tsx
+++ b/apps/web/components/recipe/recipes-header.tsx
@@ -87,6 +87,7 @@ export function RecipesHeader({
initialTag = "",
initialBatchCook = "",
initialRecipeType = "",
+ sharedUrl,
}: {
count: number;
initialQuery?: string;
@@ -96,13 +97,19 @@ export function RecipesHeader({
initialTag?: string;
initialBatchCook?: string;
initialRecipeType?: string;
+ /** A link shared into the installed PWA via the OS share sheet (Web Share
+ * Target, Chromium/Android only) — see manifest.ts's share_target and
+ * page.tsx's read of the `url`/`text` params it's redirected here with.
+ * Auto-opens the import dialog pre-filled instead of requiring the user
+ * to paste the link again. */
+ sharedUrl?: string;
}) {
const router = useRouter();
const pathname = usePathname();
const t = useTranslations("recipes");
const tRecipe = useTranslations("recipe");
const [aiOpen, setAiOpen] = useState(false);
- const [urlOpen, setUrlOpen] = useState(false);
+ const [urlOpen, setUrlOpen] = useState(!!sharedUrl);
const [query, setQuery] = useState(initialQuery);
const [, startTransition] = useTransition();
@@ -314,7 +321,7 @@ export function RecipesHeader({
-
+
>
);
}
diff --git a/apps/web/components/recipe/url-import-dialog.tsx b/apps/web/components/recipe/url-import-dialog.tsx
index fae9738..3b35ada 100644
--- a/apps/web/components/recipe/url-import-dialog.tsx
+++ b/apps/web/components/recipe/url-import-dialog.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState } from "react";
+import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Link2, Loader2 } from "lucide-react";
@@ -25,15 +25,23 @@ type ImportedRecipe = {
export function UrlImportDialog({
open,
onOpenChange,
+ initialUrl,
+ autoImport,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
+ /** Prefills (and, with autoImport, triggers) the import — used by the OS
+ * Share Target flow (apps/web/app/(app)/recipes/page.tsx) so sharing a
+ * link into the installed PWA lands here already filled in. */
+ initialUrl?: string;
+ autoImport?: boolean;
}) {
const t = useTranslations("recipe");
const tCommon = useTranslations("common");
const router = useRouter();
- const [url, setUrl] = useState("");
+ const [url, setUrl] = useState(initialUrl ?? "");
const [importing, setImporting] = useState(false);
+ const autoImportFired = useRef(false);
async function handleImport() {
if (!url.trim()) return;
@@ -77,6 +85,14 @@ export function UrlImportDialog({
}
}
+ useEffect(() => {
+ if (open && autoImport && initialUrl?.trim() && !autoImportFired.current) {
+ autoImportFired.current = true;
+ void handleImport();
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [open, autoImport, initialUrl]);
+
return (