feat: share screenshots into mobile capture (Closes #739)
This commit is contained in:
parent
11347e5fe0
commit
623817eb7d
|
|
@ -544,6 +544,8 @@
|
||||||
text: shareParams.get('text') || '',
|
text: shareParams.get('text') || '',
|
||||||
url: shareParams.get('url') || '',
|
url: shareParams.get('url') || '',
|
||||||
};
|
};
|
||||||
|
const sharedImageMarker = shareParams.get('shared') || '';
|
||||||
|
let sharedImageHandled = false;
|
||||||
const commentActionFeatures = createFeatureLoader({
|
const commentActionFeatures = createFeatureLoader({
|
||||||
document,
|
document,
|
||||||
urls: {
|
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({
|
const pullWorkflowFeatures = createFeatureLoader({
|
||||||
document,
|
document,
|
||||||
urls: {
|
urls: {
|
||||||
|
|
@ -3654,6 +3656,20 @@
|
||||||
updateIssueCreateActions();
|
updateIssueCreateActions();
|
||||||
qs('#create-issue-sheet').classList.add('open');
|
qs('#create-issue-sheet').classList.add('open');
|
||||||
creatingIssue = true;
|
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();
|
qs('#create-issue-title').focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1001,6 +1001,7 @@
|
||||||
<script src="static/widgets.js"></script>
|
<script src="static/widgets.js"></script>
|
||||||
<script src="static/drafts.js"></script>
|
<script src="static/drafts.js"></script>
|
||||||
<script src="static/unfiled-captures.js"></script>
|
<script src="static/unfiled-captures.js"></script>
|
||||||
|
<script src="static/shared-image-capture.js"></script>
|
||||||
<script src="static/draft-filing-session.js"></script>
|
<script src="static/draft-filing-session.js"></script>
|
||||||
<script src="static/draft-capacity-dialog.js"></script>
|
<script src="static/draft-capacity-dialog.js"></script>
|
||||||
<script src="static/outbox-coordinator.js"></script>
|
<script src="static/outbox-coordinator.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,13 @@
|
||||||
],
|
],
|
||||||
"share_target": {
|
"share_target": {
|
||||||
"action": "./",
|
"action": "./",
|
||||||
"method": "GET",
|
"method": "POST",
|
||||||
"enctype": "application/x-www-form-urlencoded",
|
"enctype": "multipart/form-data",
|
||||||
"params": {"title": "title", "text": "text", "url": "url"}
|
"params": {
|
||||||
|
"title": "title",
|
||||||
|
"text": "text",
|
||||||
|
"url": "url",
|
||||||
|
"files": [{"name": "image", "accept": ["image/png", "image/jpeg", "image/webp"]}]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ const SHELL = [
|
||||||
BASE + 'static/widgets.js',
|
BASE + 'static/widgets.js',
|
||||||
BASE + 'static/drafts.js',
|
BASE + 'static/drafts.js',
|
||||||
BASE + 'static/unfiled-captures.js',
|
BASE + 'static/unfiled-captures.js',
|
||||||
|
BASE + 'static/shared-image-capture.js',
|
||||||
BASE + 'static/draft-filing-session.js',
|
BASE + 'static/draft-filing-session.js',
|
||||||
BASE + 'static/draft-capacity-dialog.js',
|
BASE + 'static/draft-capacity-dialog.js',
|
||||||
BASE + 'static/outbox-coordinator.js',
|
BASE + 'static/outbox-coordinator.js',
|
||||||
|
|
@ -88,6 +89,44 @@ const SHELL = [
|
||||||
const OPTIONAL_FEATURES = [
|
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) {
|
async function fetchNavigation(request) {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
let timeout;
|
let timeout;
|
||||||
|
|
@ -464,6 +503,14 @@ self.addEventListener('notificationclick', event => {
|
||||||
|
|
||||||
self.addEventListener('fetch', event => {
|
self.addEventListener('fetch', event => {
|
||||||
const request = event.request;
|
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;
|
if (request.method !== 'GET' || request.url.includes('/api/')) return;
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
if (url.origin !== self.location.origin || !url.pathname.startsWith(BASE)) return;
|
if (url.origin !== self.location.origin || !url.pathname.startsWith(BASE)) return;
|
||||||
|
|
|
||||||
29
frontend/shared-image-capture.js
Normal file
29
frontend/shared-image-capture.js
Normal file
|
|
@ -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};
|
||||||
|
});
|
||||||
|
|
@ -13,7 +13,7 @@ def run_worker_scenario(scenario: str) -> dict:
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const vm = require('vm');
|
const vm = require('vm');
|
||||||
const listeners = {{}};
|
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();
|
const storedResponses = new Map();
|
||||||
storedResponses.set(
|
storedResponses.set(
|
||||||
'https://forge.example/dashboard/__offline-session-lease',
|
'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' }},
|
location: {{ href: 'https://forge.example/dashboard/service-worker.js', origin: 'https://forge.example' }},
|
||||||
__STACKCHAIN_NAVIGATION_TIMEOUT_MS: 15,
|
__STACKCHAIN_NAVIGATION_TIMEOUT_MS: 15,
|
||||||
__STACKCHAIN_PUSH_ACTION_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; }},
|
addEventListener: (name, handler) => {{ listeners[name] = handler; }},
|
||||||
skipWaiting: async () => {{ state.skipped = true; }},
|
skipWaiting: async () => {{ state.skipped = true; }},
|
||||||
clients: {{
|
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
|
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():
|
def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag():
|
||||||
result = run_worker_scenario(
|
result = run_worker_scenario(
|
||||||
"""
|
"""
|
||||||
|
|
@ -733,6 +791,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
"/dashboard/static/widgets.js",
|
"/dashboard/static/widgets.js",
|
||||||
"/dashboard/static/drafts.js",
|
"/dashboard/static/drafts.js",
|
||||||
"/dashboard/static/unfiled-captures.js",
|
"/dashboard/static/unfiled-captures.js",
|
||||||
|
"/dashboard/static/shared-image-capture.js",
|
||||||
"/dashboard/static/draft-filing-session.js",
|
"/dashboard/static/draft-filing-session.js",
|
||||||
"/dashboard/static/draft-capacity-dialog.js",
|
"/dashboard/static/draft-capacity-dialog.js",
|
||||||
"/dashboard/static/outbox-coordinator.js",
|
"/dashboard/static/outbox-coordinator.js",
|
||||||
|
|
|
||||||
54
tests/test_shared_image_capture.py
Normal file
54
tests/test_shared_image_capture.py
Normal file
|
|
@ -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."],
|
||||||
|
}
|
||||||
|
|
@ -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()["start_url"] == "./"
|
||||||
assert manifest.json()["scope"] == "./"
|
assert manifest.json()["scope"] == "./"
|
||||||
assert manifest.json()["share_target"] == {
|
assert manifest.json()["share_target"] == {
|
||||||
"action": "./", "method": "GET", "enctype": "application/x-www-form-urlencoded",
|
"action": "./", "method": "POST", "enctype": "multipart/form-data",
|
||||||
"params": {"title": "title", "text": "text", "url": "url"},
|
"params": {
|
||||||
|
"title": "title", "text": "text", "url": "url",
|
||||||
|
"files": [{"name": "image", "accept": ["image/png", "image/jpeg", "image/webp"]}],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
assert manifest.json()["shortcuts"] == [
|
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.url.includes('/api/')" in worker.text
|
||||||
assert "request.method !== 'GET'" in worker.text
|
assert "request.method !== 'GET'" in worker.text
|
||||||
assert "new URL('./', self.location.href).pathname" 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 '<script src="static/shared-image-capture.js"></script>' in (Path(__file__).resolve().parents[1] / "frontend" / "index.html").read_text()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user