From cae73f1f1b35d7b677dc5fc859cdf48ec0a987aa Mon Sep 17 00:00:00 2001 From: Jake Archibald Date: Mon, 17 Jun 2019 09:42:10 +0100 Subject: [PATCH] Add share target (#469) * Quick test * More testing * More testing * Removing transfer for now * Changing name so it's easier to tell them apart when installed * Disable minification to ease debugging * Adding navigate lock * lol oops * Add minifying back * Removing minification again, for debugging * Removing broadcast channel bits, to simplify the code * Revert "Removing broadcast channel bits, to simplify the code" This reverts commit 0b2a3ecf2986aae0dd65fdd1ddda2bd9e4e1eac7. * I think this fixes it * Refactor * Suppress flash of home screen during share target * Almost ready, so switching to real name * Removing log * Ahh yes the trailing comma thing * Removing use of BroadcastChannel * Reducing ternary --- package-lock.json | 51 +++++++++------------------ src/components/App/index.tsx | 34 ++++++++++++------ src/components/Options/index.tsx | 1 + src/components/compress/index.tsx | 4 ++- src/lib/{offliner.ts => sw-bridge.ts} | 17 +++++++++ src/manifest.json | 15 +++++++- src/sw/index.ts | 16 +++++++-- src/sw/util.ts | 45 ++++++++++++++++++++++- 8 files changed, 132 insertions(+), 51 deletions(-) rename src/lib/{offliner.ts => sw-bridge.ts} (84%) diff --git a/package-lock.json b/package-lock.json index 297fa2bd..efd1c8c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2649,7 +2649,7 @@ "dependencies": { "get-stream": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", + "resolved": "http://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=", "dev": true }, @@ -5465,8 +5465,7 @@ "ansi-regex": { "version": "2.1.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "aproba": { "version": "1.2.0", @@ -5487,14 +5486,12 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, - "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -5509,20 +5506,17 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "core-util-is": { "version": "1.0.2", @@ -5639,8 +5633,7 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "ini": { "version": "1.3.5", @@ -5652,7 +5645,6 @@ "version": "1.0.0", "bundled": true, "dev": true, - "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -5667,7 +5659,6 @@ "version": "3.0.4", "bundled": true, "dev": true, - "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -5675,14 +5666,12 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "minipass": { "version": "2.3.5", "bundled": true, "dev": true, - "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -5701,7 +5690,6 @@ "version": "0.5.1", "bundled": true, "dev": true, - "optional": true, "requires": { "minimist": "0.0.8" } @@ -5782,8 +5770,7 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "object-assign": { "version": "4.1.1", @@ -5795,7 +5782,6 @@ "version": "1.4.0", "bundled": true, "dev": true, - "optional": true, "requires": { "wrappy": "1" } @@ -5881,8 +5867,7 @@ "safe-buffer": { "version": "5.1.2", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "safer-buffer": { "version": "2.1.2", @@ -5918,7 +5903,6 @@ "version": "1.0.2", "bundled": true, "dev": true, - "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -5938,7 +5922,6 @@ "version": "3.0.1", "bundled": true, "dev": true, - "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -5982,14 +5965,12 @@ "wrappy": { "version": "1.0.2", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "yallist": { "version": "3.0.3", "bundled": true, - "dev": true, - "optional": true + "dev": true } } }, @@ -6096,7 +6077,7 @@ "dependencies": { "get-stream": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", + "resolved": "http://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=", "dev": true }, @@ -6286,7 +6267,7 @@ "dependencies": { "get-stream": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", + "resolved": "http://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=", "dev": true } @@ -7353,7 +7334,7 @@ }, "is-obj": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "resolved": "http://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", "integrity": "sha1-PkcprB9f3gJc19g6iW2rn09n2w8=", "dev": true }, @@ -12054,7 +12035,7 @@ }, "query-string": { "version": "5.1.1", - "resolved": "https://registry.npmjs.org/query-string/-/query-string-5.1.1.tgz", + "resolved": "http://registry.npmjs.org/query-string/-/query-string-5.1.1.tgz", "integrity": "sha512-gjWOsm2SoGlgLEdAGt7a6slVOk9mGiXmPFMqrEhLQ68rhQuBnpfs3+EmlvqKyxnCo9/PPlF+9MtY02S1aFg+Jw==", "dev": true, "requires": { diff --git a/src/components/App/index.tsx b/src/components/App/index.tsx index c93c1c33..a46c39fc 100644 --- a/src/components/App/index.tsx +++ b/src/components/App/index.tsx @@ -14,9 +14,10 @@ const ROUTE_EDITOR = '/editor'; const compressPromise = import( /* webpackChunkName: "main-app" */ '../compress'); -const offlinerPromise = import( - /* webpackChunkName: "offliner" */ - '../../lib/offliner'); + +const swBridgePromise = import( + /* webpackChunkName: "sw-bridge" */ + '../../lib/sw-bridge'); function back() { window.history.back(); @@ -25,6 +26,7 @@ function back() { interface Props {} interface State { + awaitingShareTarget: boolean; file?: File | Fileish; isEditorOpen: Boolean; Compress?: typeof import('../compress').default; @@ -32,6 +34,7 @@ interface State { export default class App extends Component { state: State = { + awaitingShareTarget: new URL(location.href).searchParams.has('share-target'), isEditorOpen: false, file: undefined, Compress: undefined, @@ -48,7 +51,15 @@ export default class App extends Component { this.showSnack('Failed to load app'); }); - offlinerPromise.then(({ offliner }) => offliner(this.showSnack)); + swBridgePromise.then(async ({ offliner, getSharedImage }) => { + offliner(this.showSnack); + if (!this.state.awaitingShareTarget) return; + const file = await getSharedImage(); + // Remove the ?share-target from the URL + history.replaceState('', '', '/'); + this.openEditor(); + this.setState({ file, awaitingShareTarget: false }); + }); // In development, persist application state across hot reloads: if (process.env.NODE_ENV === 'development') { @@ -103,15 +114,18 @@ export default class App extends Component { this.setState({ isEditorOpen: true }); } - render({}: Props, { file, isEditorOpen, Compress }: State) { + render({}: Props, { file, isEditorOpen, Compress, awaitingShareTarget }: State) { + const showSpinner = awaitingShareTarget || (isEditorOpen && !Compress); + return (
- {!isEditorOpen - ? - : (Compress) - ? - : + { + showSpinner + ? + : isEditorOpen + ? Compress && + : } diff --git a/src/components/Options/index.tsx b/src/components/Options/index.tsx index 5d2b1d31..1072d3ad 100644 --- a/src/components/Options/index.tsx +++ b/src/components/Options/index.tsx @@ -178,6 +178,7 @@ export default class Options extends Component { {encoderSupportMap ? diff --git a/src/components/compress/index.tsx b/src/components/compress/index.tsx index fd727dc6..a63b55bf 100644 --- a/src/components/compress/index.tsx +++ b/src/components/compress/index.tsx @@ -244,7 +244,7 @@ export default class Compress extends Component { this.widthQuery.addListener(this.onMobileWidthChange); this.updateFile(props.file); - import('../../lib/offliner').then(({ mainAppLoaded }) => mainAppLoaded()); + import('../../lib/sw-bridge').then(({ mainAppLoaded }) => mainAppLoaded()); } @bind @@ -567,6 +567,7 @@ export default class Compress extends Component { const [leftImageData, rightImageData] = sides.map(i => i.data); const options = sides.map((side, index) => ( + // tslint:disable-next-line:jsx-key { (mobileView ? ['down', 'up'] : ['right', 'left']) as CopyAcrossIconProps['copyDirection'][]; const results = sides.map((side, index) => ( + // tslint:disable-next-line:jsx-key { }); } +/** Wait for a shared image */ +export function getSharedImage(): Promise { + return new Promise((resolve) => { + const onmessage = (event: MessageEvent) => { + if (event.data.action !== 'load-image') return; + resolve(event.data.file); + navigator.serviceWorker.removeEventListener('message', onmessage); + }; + + navigator.serviceWorker.addEventListener('message', onmessage); + + // This message is picked up by the service worker - it's how it knows we're ready to receive + // the file. + navigator.serviceWorker.controller!.postMessage('share-ready'); + }); +} + /** Set up the service worker and monitor changes */ export async function offliner(showSnack: SnackBarElement['showSnackbar']) { // This needs to be a typeof because Webpack. diff --git a/src/manifest.json b/src/manifest.json index ba4e09cf..a7c70545 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -12,5 +12,18 @@ "type": "image/png", "sizes": "1024x1024" } - ] + ], + "share_target": { + "action": "/?share-target", + "method": "POST", + "enctype": "multipart/form-data", + "params": { + "files": [ + { + "name": "file", + "accept": ["image/*"] + } + ] + } + } } diff --git a/src/sw/index.ts b/src/sw/index.ts index d834bdda..7d96b18b 100644 --- a/src/sw/index.ts +++ b/src/sw/index.ts @@ -1,5 +1,6 @@ import { cacheOrNetworkAndCache, cleanupCache, cacheOrNetwork, cacheBasics, cacheAdditionalProcessors, + serveShareTarget, } from './util'; import { get } from 'idb-keyval'; @@ -40,14 +41,23 @@ self.addEventListener('activate', (event) => { }); self.addEventListener('fetch', (event) => { - // We only care about GET. - if (event.request.method !== 'GET') return; - const url = new URL(event.request.url); // Don't care about other-origin URLs if (url.origin !== location.origin) return; + if ( + url.pathname === '/' && + url.searchParams.has('share-target') && + event.request.method === 'POST' + ) { + serveShareTarget(event); + return; + } + + // We only care about GET from here on in. + if (event.request.method !== 'GET') return; + if (url.pathname.startsWith('/demo-') || url.pathname.startsWith('/wc-polyfill')) { cacheOrNetworkAndCache(event, dynamicCache); cleanupCache(event, dynamicCache, BUILD_ASSETS); diff --git a/src/sw/util.ts b/src/sw/util.ts index c4750807..cb7156b3 100644 --- a/src/sw/util.ts +++ b/src/sw/util.ts @@ -1,8 +1,11 @@ import webpDataUrl from 'url-loader!../codecs/tiny.webp'; +// Give TypeScript the correct global. +declare var self: ServiceWorkerGlobalScope; + export function cacheOrNetwork(event: FetchEvent): void { event.respondWith(async function () { - const cachedResponse = await caches.match(event.request); + const cachedResponse = await caches.match(event.request, { ignoreSearch: true }); return cachedResponse || fetch(event.request); }()); } @@ -29,6 +32,23 @@ export function cacheOrNetworkAndCache(event: FetchEvent, cacheName: string): vo }()); } +export function serveShareTarget(event: FetchEvent): void { + const dataPromise = event.request.formData(); + + // Redirect so the user can refresh the page without resending data. + // @ts-ignore It doesn't like me giving a response to respondWith, although it's allowed. + event.respondWith(Response.redirect('/?share-target')); + + event.waitUntil(async function () { + // The page sends this message to tell the service worker it's ready to receive the file. + await nextMessage('share-ready'); + const client = await self.clients.get(event.resultingClientId); + const data = await dataPromise; + const file = data.get('file'); + client.postMessage({ file, action: 'load-image' }); + }()); +} + export function cleanupCache(event: FetchEvent, cacheName: string, keepAssets: string[]) { event.waitUntil(async function () { const cache = await caches.open(cacheName); @@ -104,3 +124,26 @@ export async function cacheAdditionalProcessors(cacheName: string, buildAssets: const cache = await caches.open(cacheName); await cache.addAll(toCache); } + +const nextMessageResolveMap = new Map void)[]>(); + +/** + * Wait on a message with a particular event.data value. + * + * @param dataVal The event.data value. + */ +function nextMessage(dataVal: string): Promise { + return new Promise((resolve) => { + if (!nextMessageResolveMap.has(dataVal)) { + nextMessageResolveMap.set(dataVal, []); + } + nextMessageResolveMap.get(dataVal)!.push(resolve); + }); +} + +self.addEventListener('message', (event) => { + const resolvers = nextMessageResolveMap.get(event.data); + if (!resolvers) return; + nextMessageResolveMap.delete(event.data); + for (const resolve of resolvers) resolve(); +});