From 36e26ff72370572f47405481648fa8ddd8718fd6 Mon Sep 17 00:00:00 2001 From: Simon Ramsay Date: Mon, 17 Nov 2025 04:42:19 +0000 Subject: [PATCH] enable pwa share target - adds share_target property to manifest - configure to send POST to new url , /share-target - going with POST so that files can be shared in the future -> https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/share_target#receiving_shared_files - updated webworker to intercept fetch requests, and redirect+restructure /share-target requests to SPA page with paramters moved to the url params (mostly generated by gpt5 using "i want to add a pwa share target that invokes the publish message button on the web app") --- Makefile | 2 +- server/server.go | 10 ++++++++ server/types.go | 15 ++++++++++++ web/package.json | 2 +- web/public/sw.js | 34 ++++++++++++++++++++++++++++ web/src/components/App.jsx | 31 ++++++++++++++++++++++++- web/src/components/Messaging.jsx | 4 +++- web/src/components/PublishDialog.jsx | 12 ++++++++++ 8 files changed, 106 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index df131c7a..8c76c879 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ MAKEFLAGS := --jobs=1 PYTHON := python3 PIP := pip3 -VERSION := $(shell git describe --tag) +VERSION := 1111 COMMIT := $(shell git rev-parse --short HEAD) .PHONY: diff --git a/server/server.go b/server/server.go index fc04d50f..848fcab7 100644 --- a/server/server.go +++ b/server/server.go @@ -636,6 +636,16 @@ func (s *Server) handleWebManifest(w http.ResponseWriter, _ *http.Request, _ *vi {SRC: "/static/images/pwa-192x192.png", Sizes: "192x192", Type: "image/png"}, {SRC: "/static/images/pwa-512x512.png", Sizes: "512x512", Type: "image/png"}, }, + ShareTarget: &webManifestShareTarget{ + Action: "/share-target", + Method: "POST", + Enctype: "multipart/form-data", + Params: &webManifestShareParams{ + Title: "title", + Text: "text", + Url: "url", + }, + }, } return s.writeJSONWithContentType(w, response, "application/manifest+json") } diff --git a/server/types.go b/server/types.go index d9519b94..668cbeb0 100644 --- a/server/types.go +++ b/server/types.go @@ -587,6 +587,8 @@ type webManifestResponse struct { BackgroundColor string `json:"background_color"` ThemeColor string `json:"theme_color"` Icons []*webManifestIcon `json:"icons"` + // Optional PWA share_target support + ShareTarget *webManifestShareTarget `json:"share_target,omitempty"` } type webManifestIcon struct { @@ -594,3 +596,16 @@ type webManifestIcon struct { Sizes string `json:"sizes"` Type string `json:"type"` } + +type webManifestShareTarget struct { + Action string `json:"action"` + Method string `json:"method"` + Enctype string `json:"enctype"` + Params *webManifestShareParams `json:"params"` +} + +type webManifestShareParams struct { + Title string `json:"title,omitempty"` + Text string `json:"text,omitempty"` + Url string `json:"url,omitempty"` +} diff --git a/web/package.json b/web/package.json index 0de56abd..180b17f3 100644 --- a/web/package.json +++ b/web/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "private": true, "scripts": { - "start": "NODE_OPTIONS=\"--enable-source-maps\" vite", + "start": "NODE_OPTIONS=\"--enable-source-maps\" vite --host", "build": "vite build", "serve": "vite preview", "format": "prettier . --write", diff --git a/web/public/sw.js b/web/public/sw.js index 56d66f16..1ccf4b88 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -221,6 +221,40 @@ self.addEventListener("notificationclick", (event) => { event.waitUntil(handleClick(event)); }); +// Handle incoming Share Target POSTs from installed PWA share action. +// The share_target in the manifest posts to `/share-target` as a `multipart/form-data` POST. +// We parse the form and redirect to the app with query params so the client can pick them up. +self.addEventListener("fetch", (event) => { + try { + const reqUrl = new URL(event.request.url); + if (reqUrl.pathname === "/share-target" && event.request.method === "POST") { + event.respondWith( + (async () => { + try { + const formData = await event.request.formData(); + const title = formData.get("title") || ""; + const text = formData.get("text") || ""; + const sharedUrl = formData.get("url") || ""; + const params = new URLSearchParams(); + if (title) params.set("share_title", title); + if (text) params.set("share_text", text); + if (sharedUrl) params.set("share_url", sharedUrl); + // todo support files? + // redirect to app.html which is the SPA entry used by the service worker + const redirectUrl = `/app.html?${params.toString()}`; + return Response.redirect(redirectUrl, 303); + } catch (e) { + return new Response("", { status: 500 }); + } + })() + ); + } + // other fetches are not handled here + } catch (e) { + // ignore malformed urls + } +}); + // See https://vite-pwa-org.netlify.app/guide/inject-manifest.html#service-worker-code // self.__WB_MANIFEST is the workbox injection point that injects the manifest of the // vite dist files and their revision ids, for example: diff --git a/web/src/components/App.jsx b/web/src/components/App.jsx index 9a2c3e66..eb0fa810 100644 --- a/web/src/components/App.jsx +++ b/web/src/components/App.jsx @@ -104,6 +104,7 @@ const Layout = () => { const { account, setAccount } = useContext(AccountContext); const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false); const [sendDialogOpenMode, setSendDialogOpenMode] = useState(""); + const [sharePayload, setSharePayload] = useState(null); const users = useLiveQuery(() => userManager.all()); const subscriptions = useLiveQuery(() => subscriptionManager.all()); const webPushTopics = useWebPushTopics(); @@ -120,6 +121,29 @@ const Layout = () => { useBackgroundProcesses(); useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]); + // Check URL for share-target params inserted by the service worker redirect + useEffect(() => { + try { + const params = new URLSearchParams(window.location.search); + const title = params.get("share_title"); + const text = params.get("share_text"); + const url = params.get(" share_url"); + if (title || text || url) { + const payload = { + title: title || undefined, + message: text || undefined, + url: url || undefined, + }; + setSharePayload(payload); + setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT); + // remove params to avoid repeated triggers + window.history.replaceState({}, document.title, window.location.pathname + window.location.hash); + } + } catch (e) { + // ignore + } + }, []); + return ( setMobileDrawerOpen(!mobileDrawerOpen)} /> @@ -139,7 +163,12 @@ const Layout = () => { }} /> - + ); }; diff --git a/web/src/components/Messaging.jsx b/web/src/components/Messaging.jsx index f3610fd0..b619a67c 100644 --- a/web/src/components/Messaging.jsx +++ b/web/src/components/Messaging.jsx @@ -53,7 +53,9 @@ const Messaging = (props) => { openMode={dialogOpenMode} baseUrl={subscription?.baseUrl ?? config.base_url} topic={subscription?.topic ?? ""} - message={message} + message={props.sharePayload?.message ?? message} + title={props.sharePayload?.title} + clickUrl={props.sharePayload?.url} attachFile={attachFile} getPastedImage={getPastedImage} onClose={handleDialogClose} diff --git a/web/src/components/PublishDialog.jsx b/web/src/components/PublishDialog.jsx index 912810b3..33164ad9 100644 --- a/web/src/components/PublishDialog.jsx +++ b/web/src/components/PublishDialog.jsx @@ -109,6 +109,18 @@ const PublishDialog = (props) => { setMessage(props.message); }, [props.message]); + useEffect(() => { + if (typeof props.title !== "undefined") { + setTitle(props.title || ""); + } + }, [props.title]); + + useEffect(() => { + if (typeof props.clickUrl !== "undefined") { + setClickUrl(props.clickUrl || ""); + } + }, [props.clickUrl]); + const updateBaseUrl = (newVal) => { if (validUrl(newVal)) { setBaseUrl(newVal.replace(/\/$/, "")); // strip traililng slash after https?://