diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 6d874dd..1314693 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -544,6 +544,8 @@ text: shareParams.get('text') || '', url: shareParams.get('url') || '', }; + const sharedImageMarker = shareParams.get('shared') || ''; + let sharedImageHandled = false; const commentActionFeatures = createFeatureLoader({ document, urls: { @@ -583,7 +585,7 @@ } }); } - if (Object.values(sharedLaunch).some(Boolean)) await ensureIssueCapture(); + if (Object.values(sharedLaunch).some(Boolean) || sharedImageMarker) await ensureIssueCapture(); const pullWorkflowFeatures = createFeatureLoader({ document, urls: { @@ -3654,6 +3656,20 @@ updateIssueCreateActions(); qs('#create-issue-sheet').classList.add('open'); creatingIssue = true; + if (!sharedImageHandled && sharedImageMarker) { + sharedImageHandled = true; + if (createIssueAttachmentController.state()) { + qs('#create-issue-attachment-status').textContent = 'Remove the current screenshot before adding the shared screenshot.'; + } else { + await sharedImageCapture.consume({ + marker:sharedImageMarker, + store:unfiledAttachmentStore, + restore:value=>createIssueAttachmentController.restore(value), + status:message=>{ qs('#create-issue-attachment-status').textContent = message; }, + }); + } + clearSharedLaunchUrl(); + } qs('#create-issue-title').focus(); } diff --git a/frontend/index.html b/frontend/index.html index b526b8e..177116b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1001,6 +1001,7 @@ + diff --git a/frontend/manifest.webmanifest b/frontend/manifest.webmanifest index 7b8dd77..8c5f1d2 100644 --- a/frontend/manifest.webmanifest +++ b/frontend/manifest.webmanifest @@ -37,8 +37,13 @@ ], "share_target": { "action": "./", - "method": "GET", - "enctype": "application/x-www-form-urlencoded", - "params": {"title": "title", "text": "text", "url": "url"} + "method": "POST", + "enctype": "multipart/form-data", + "params": { + "title": "title", + "text": "text", + "url": "url", + "files": [{"name": "image", "accept": ["image/png", "image/jpeg", "image/webp"]}] + } } } diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 8ca8e51..f056dbc 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -21,6 +21,7 @@ const SHELL = [ BASE + 'static/widgets.js', BASE + 'static/drafts.js', BASE + 'static/unfiled-captures.js', + BASE + 'static/shared-image-capture.js', BASE + 'static/draft-filing-session.js', BASE + 'static/draft-capacity-dialog.js', BASE + 'static/outbox-coordinator.js', @@ -88,6 +89,44 @@ const SHELL = [ const OPTIONAL_FEATURES = [ ]; +const SHARED_IMAGE_ID = 'shared-image'; +const SHARED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']); +const MAX_SHARED_IMAGE_BYTES = 12 * 1024 * 1024; +const sharedAttachmentStore = self.__STACKCHAIN_SHARED_ATTACHMENT_STORE || createUnfiledAttachmentStore(); + +function boundedShareField(form, name, limit) { + const value = form.get(name); + return typeof value === 'string' ? value.trim().slice(0, limit) : ''; +} + +async function acceptSharedContent(request) { + const form = await request.formData(); + const images = form.getAll('image').filter(value => typeof value !== 'string' && value?.size > 0); + let marker = ''; + await sharedAttachmentStore.delete(SHARED_IMAGE_ID).catch(() => {}); + if (images.length > 1) marker = 'multiple'; + else if (images.length === 1) { + const image = images[0]; + if (!SHARED_IMAGE_TYPES.has(String(image.type || '')) || image.size > MAX_SHARED_IMAGE_BYTES) { + marker = 'unsupported'; + } else { + await sharedAttachmentStore.put(SHARED_IMAGE_ID, { + filename:String(image.name || 'shared-screenshot').slice(0, 255), + contentType:String(image.type), blob:image, + }); + marker = 'image'; + } + } + const target = new URL(BASE, self.location.origin); + target.searchParams.set('launch', 'new'); + if (marker) target.searchParams.set('shared', marker); + for (const [name, limit] of [['title', 255], ['text', 10000], ['url', 2048]]) { + const value = boundedShareField(form, name, limit); + if (value) target.searchParams.set(name, value); + } + return new Response(null, {status:303, headers:{Location:target.pathname + target.search}}); +} + async function fetchNavigation(request) { const controller = new AbortController(); let timeout; @@ -464,6 +503,14 @@ self.addEventListener('notificationclick', event => { self.addEventListener('fetch', event => { const request = event.request; + const requestUrl = new URL(request.url); + if ( + request.method === 'POST' && request.mode === 'navigate' + && requestUrl.origin === self.location.origin && requestUrl.pathname === BASE + ) { + event.respondWith(acceptSharedContent(request)); + return; + } if (request.method !== 'GET' || request.url.includes('/api/')) return; const url = new URL(request.url); if (url.origin !== self.location.origin || !url.pathname.startsWith(BASE)) return; diff --git a/frontend/shared-image-capture.js b/frontend/shared-image-capture.js new file mode 100644 index 0000000..1b4f2b6 --- /dev/null +++ b/frontend/shared-image-capture.js @@ -0,0 +1,29 @@ +(function(root, factory) { + const api = factory(); + if (typeof module === 'object' && module.exports) module.exports = api; + else root.sharedImageCapture = api; +})(typeof self !== 'undefined' ? self : this, function() { + 'use strict'; + + const RECORD_ID = 'shared-image'; + const TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']); + + async function consume({marker, store, restore, status = () => {}}) { + if (!marker) return false; + if (marker !== 'image') { + status('Share one PNG, JPEG, or WebP screenshot.'); + return false; + } + const value = await store?.get(RECORD_ID); + if (!value?.blob || !value.filename || !TYPES.has(String(value.contentType || ''))) { + status('The shared screenshot is unavailable. Share it again.'); + return false; + } + restore(value); + await store.delete(RECORD_ID); + status('Shared screenshot ready to file with this issue.'); + return true; + } + + return {consume, RECORD_ID}; +}); \ No newline at end of file diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index a94821d..27f4e0e 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -13,7 +13,7 @@ def run_worker_scenario(scenario: str) -> dict: const fs = require('fs'); const vm = require('vm'); const listeners = {{}}; -const state = {{ added: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }}; +const state = {{ added: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], sharedRecords: {{}}, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }}; const storedResponses = new Map(); storedResponses.set( 'https://forge.example/dashboard/__offline-session-lease', @@ -46,6 +46,11 @@ const context = {{ location: {{ href: 'https://forge.example/dashboard/service-worker.js', origin: 'https://forge.example' }}, __STACKCHAIN_NAVIGATION_TIMEOUT_MS: 15, __STACKCHAIN_PUSH_ACTION_TIMEOUT_MS: 15, + __STACKCHAIN_SHARED_ATTACHMENT_STORE: {{ + put: async (id, value) => {{ state.sharedRecords[id] = value; }}, + get: async id => state.sharedRecords[id] || null, + delete: async id => {{ delete state.sharedRecords[id]; }}, + }}, addEventListener: (name, handler) => {{ listeners[name] = handler; }}, skipWaiting: async () => {{ state.skipped = true; }}, clients: {{ @@ -211,6 +216,59 @@ def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): assert "BASE + 'static/update-ownership.js'" in source +def test_image_share_target_stages_one_supported_image_and_redirects_to_capture(): + result = run_worker_scenario( + """ + const image = new Blob(['pixels'], {type:'image/png'}); + Object.defineProperty(image, 'name', {value:'bug.png'}); + const form = new FormData(); + form.append('title', 'Broken checkout'); + form.append('text', 'Steps to reproduce'); + form.append('url', 'https://example.test/checkout'); + form.append('image', image, 'bug.png'); + const request = new Request('https://forge.example/dashboard/', {method:'POST', body:form}); + Object.defineProperty(request, 'mode', {value:'navigate'}); + const response = await dispatch('fetch', request); + const [id, record] = Object.entries(state.sharedRecords)[0]; + process.stdout.write(JSON.stringify({ + status:response.status, location:response.headers.get('Location'), id, + filename:record.filename, contentType:record.contentType, size:record.blob.size, + })); +""" + ) + + assert result == { + "status": 303, + "location": "/dashboard/?launch=new&shared=image&title=Broken+checkout&text=Steps+to+reproduce&url=https%3A%2F%2Fexample.test%2Fcheckout", + "id": "shared-image", + "filename": "bug.png", + "contentType": "image/png", + "size": 6, + } + + +def test_image_share_target_rejects_multiple_images_without_staging_private_data(): + result = run_worker_scenario( + """ + const form = new FormData(); + form.append('image', new Blob(['one'], {type:'image/png'}), 'one.png'); + form.append('image', new Blob(['two'], {type:'image/jpeg'}), 'two.jpg'); + const request = new Request('https://forge.example/dashboard/', {method:'POST', body:form}); + Object.defineProperty(request, 'mode', {value:'navigate'}); + const response = await dispatch('fetch', request); + process.stdout.write(JSON.stringify({ + status:response.status, location:response.headers.get('Location'), records:Object.keys(state.sharedRecords), + })); +""" + ) + + assert result == { + "status": 303, + "location": "/dashboard/?launch=new&shared=multiple", + "records": [], + } + + def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag(): result = run_worker_scenario( """ @@ -733,6 +791,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/widgets.js", "/dashboard/static/drafts.js", "/dashboard/static/unfiled-captures.js", + "/dashboard/static/shared-image-capture.js", "/dashboard/static/draft-filing-session.js", "/dashboard/static/draft-capacity-dialog.js", "/dashboard/static/outbox-coordinator.js", diff --git a/tests/test_shared_image_capture.py b/tests/test_shared_image_capture.py new file mode 100644 index 0000000..3ff78cd --- /dev/null +++ b/tests/test_shared_image_capture.py @@ -0,0 +1,54 @@ +import json +import subprocess +from pathlib import Path + + +MODULE = Path(__file__).resolve().parents[1] / "frontend" / "shared-image-capture.js" + + +def test_shared_image_is_consumed_once_and_restored_into_capture(): + script = f""" +const shared = require({json.dumps(str(MODULE))}); +const calls=[]; +const store={{ + get:async id=>({{filename:'bug.png',contentType:'image/png',blob:{{size:6,type:'image/png'}}}}), + delete:async id=>calls.push(['delete',id]), +}}; +shared.consume({{ + marker:'image', store, + restore:value=>calls.push(['restore',value.filename]), + status:message=>calls.push(['status',message]), +}}).then(first=>shared.consume({{marker:'',store,restore:()=>calls.push(['restore-again'])}}).then(second=>{{ + process.stdout.write(JSON.stringify({{first,second,calls}})); +}})); +""" + completed = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert completed.returncode == 0, completed.stderr + assert json.loads(completed.stdout) == { + "first": True, + "second": False, + "calls": [ + ["restore", "bug.png"], + ["delete", "shared-image"], + ["status", "Shared screenshot ready to file with this issue."], + ], + } + + +def test_invalid_shared_image_reports_error_without_touching_open_capture(): + script = f""" +const shared = require({json.dumps(str(MODULE))}); +const calls=[]; +shared.consume({{ + marker:'unsupported', store:{{get:async()=>null,delete:async()=>calls.push('delete')}}, + restore:()=>calls.push('restore'), status:message=>calls.push(message), +}}).then(result=>process.stdout.write(JSON.stringify({{result,calls}}))); +""" + completed = subprocess.run(["node", "-e", script], capture_output=True, text=True) + + assert completed.returncode == 0, completed.stderr + assert json.loads(completed.stdout) == { + "result": False, + "calls": ["Share one PNG, JPEG, or WebP screenshot."], + } \ No newline at end of file diff --git a/tests/test_work_pages.py b/tests/test_work_pages.py index eb38961..2d14cbd 100644 --- a/tests/test_work_pages.py +++ b/tests/test_work_pages.py @@ -79,8 +79,11 @@ async def test_pwa_assets_expose_root_scoped_share_target_without_caching_api_da assert manifest.json()["start_url"] == "./" assert manifest.json()["scope"] == "./" assert manifest.json()["share_target"] == { - "action": "./", "method": "GET", "enctype": "application/x-www-form-urlencoded", - "params": {"title": "title", "text": "text", "url": "url"}, + "action": "./", "method": "POST", "enctype": "multipart/form-data", + "params": { + "title": "title", "text": "text", "url": "url", + "files": [{"name": "image", "accept": ["image/png", "image/jpeg", "image/webp"]}], + }, } assert manifest.json()["shortcuts"] == [ { @@ -108,6 +111,9 @@ async def test_pwa_assets_expose_root_scoped_share_target_without_caching_api_da assert "request.url.includes('/api/')" in worker.text assert "request.method !== 'GET'" in worker.text assert "new URL('./', self.location.href).pathname" in worker.text + assert "acceptSharedContent(request)" in worker.text + assert "sharedImageCapture.consume" in (Path(__file__).resolve().parents[1] / "frontend" / "dashboard.js").read_text() + assert '' in (Path(__file__).resolve().parents[1] / "frontend" / "index.html").read_text() @pytest.mark.anyio