feat: use binary screenshot transport (Closes #491)
CI / lint (pull_request) Successful in 1m6s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-10 15:03:45 +00:00
1 parent 59b4d5e6bf
commit 16604d697b
15 files changed
+306 -130

No files matched your search

+2 -1
View File
@@ -106,7 +106,8 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
attachment: {
filename: String(message.attachment.filename || ''),
contentType: String(message.attachment.contentType || ''),
data: String(message.attachment.data || ''),
...(message.attachment.blob ? { blob: message.attachment.blob } :
{ data: String(message.attachment.data || '') }),
},
} : candidate);
try {
+23 -15
View File
@@ -77,8 +77,10 @@ function createIssueSyncStore({
}
continue;
}
if (current.operationId === replacement.operationId && current.attachment?.data &&
replacement.attachment?.stored && !replacement.attachment.data) {
if (current.operationId === replacement.operationId &&
(current.attachment?.data || current.attachment?.blob) &&
replacement.attachment?.stored &&
!replacement.attachment.data && !replacement.attachment.blob) {
replacement = { ...replacement, attachment: current.attachment };
incoming.set(current.id, replacement);
}
@@ -153,7 +155,8 @@ function createIssueSyncStore({
if (current && ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
(current.status === 'attention' && item.status === 'attention') ||
current.status === 'sent')) return current;
const preservedAttachment = current?.attachment?.data && item?.attachment?.stored && !item.attachment.data
const preservedAttachment = (current?.attachment?.data || current?.attachment?.blob) &&
item?.attachment?.stored && !item.attachment.data && !item.attachment.blob
? { attachment: current.attachment } : {};
const next = current && current.operationId === item.operationId ? {
...item, ...preservedAttachment,
@@ -417,6 +420,19 @@ function createBackgroundIssueSync({
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
}
function attachmentMultipart(attachment) {
let blob = attachment?.blob;
if (!blob && attachment?.data) {
const binary = atob(String(attachment.data));
const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
blob = new Blob([bytes], { type: String(attachment.contentType || '') });
}
if (!blob) throw new Error('The saved screenshot is unavailable. Retry before sending.');
const form = new FormData();
form.append('file', blob, String(attachment.filename || 'screenshot'));
return form;
}
async function deliverIssueCapture(item) {
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
let deliveredIssue = item.deliveredIssue;
@@ -433,14 +449,10 @@ function createBackgroundIssueSync({
{
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
Accept: 'application/json',
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
},
body: JSON.stringify({
filename: item.attachment.filename,
content_type: item.attachment.contentType,
data: item.attachment.data,
}),
body: attachmentMultipart(item.attachment),
},
);
attachmentMarkdown = String(uploaded?.markdown || '');
@@ -476,14 +488,10 @@ function createBackgroundIssueSync({
{
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
Accept: 'application/json',
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
},
body: JSON.stringify({
filename: item.attachment.filename,
content_type: item.attachment.contentType,
data: item.attachment.data,
}),
body: attachmentMultipart(item.attachment),
},
);
attachmentMarkdown = String(uploaded?.markdown || '');
+1 -6
View File
@@ -296,14 +296,9 @@
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'Idempotency-Key': payload.operation_id,
},
body: JSON.stringify({
filename: payload.filename,
content_type: payload.content_type,
data: payload.data,
}),
body: issueAttachment.multipart(payload),
},
);
},
+23 -13
View File
@@ -8,8 +8,20 @@
const MAX_BYTES = 2 * 1024 * 1024;
const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
function multipart(attachment) {
let blob = attachment?.blob;
if (!blob && attachment?.data) {
const binary = atob(String(attachment.data));
const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
blob = new Blob([bytes], { type: String(attachment.contentType || '') });
}
if (!blob) throw new Error('The saved screenshot is unavailable. Retry before sending.');
const form = new FormData();
form.append('file', blob, String(attachment.filename || 'screenshot'));
return form;
}
function create(options) {
const readDataUrl = options.readDataUrl;
const upload = options.upload;
const createOperationId = options.createOperationId || (() => {
if (typeof globalThis !== 'undefined' && globalThis.crypto?.randomUUID) {
@@ -46,15 +58,16 @@
function restore(value) {
const contentType = String(value?.contentType || '');
const filename = String(value?.filename || '');
const blob = value?.blob;
const data = String(value?.data || '');
if (!data) {
if (!blob && !data) {
clear();
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
}
const padding = (data.match(/=*$/) || [''])[0].length;
const size = Math.max(1, Math.floor(data.length * 3 / 4) - padding);
select({ name: filename, type: contentType, size });
serialized = { filename, contentType, data };
const size = blob ? Number(blob.size) : Math.max(1, Math.floor(data.length * 3 / 4) - padding);
select({ name: filename, type: contentType, size, ...(blob ? { blob } : {}) });
serialized = blob ? { filename, contentType, blob } : { filename, contentType, data };
return state();
}
@@ -69,14 +82,10 @@
async function serialize() {
if (!selected) return null;
if (!serialized) {
const dataUrl = await readDataUrl(selected);
const marker = ';base64,';
const markerAt = String(dataUrl).indexOf(marker);
if (markerAt < 0) throw new Error('The screenshot could not be read. Choose it again.');
serialized = {
filename: selected.name,
contentType: selected.type,
data: String(dataUrl).slice(markerAt + marker.length),
blob: selected.blob || selected,
};
}
return { ...serialized };
@@ -92,7 +101,7 @@
number: item.number,
filename: attachment.filename,
content_type: attachment.contentType,
data: attachment.data,
...(attachment.blob ? { blob: attachment.blob } : { data: attachment.data }),
operation_id: operationId,
});
if (!confirmed || typeof confirmed.markdown !== 'string' || !confirmed.markdown) {
@@ -144,7 +153,8 @@
function restorePreview(value) {
clearPreview();
const restored = controller.restore(value);
previewUrl = 'data:' + value.contentType + ';base64,' + value.data;
previewUrl = value.blob ? options.createObjectURL(value.blob) :
'data:' + value.contentType + ';base64,' + value.data;
options.image.src = previewUrl;
options.meta.textContent = restored.name + ' · ' + Math.ceil(restored.size / 1024) + ' KB';
options.preview.hidden = false;
@@ -155,5 +165,5 @@
return Object.assign(controller, { clear: clearPreview, restore: restorePreview });
}
return { create, mount, MAX_BYTES };
return { create, mount, multipart, MAX_BYTES };
});
+20 -9
View File
@@ -8,8 +8,10 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
function captureAttachment(value) {
const contentType = String(value?.contentType || '');
const filename = String(value?.filename || '').slice(0, 255);
const blob = value?.blob;
const data = String(value?.data || '');
if (!filename || !['image/png', 'image/jpeg', 'image/webp'].includes(contentType)) return undefined;
if (blob) return { filename, contentType, blob };
if (!data && value?.stored === true) return { filename, contentType, stored: true };
if (!data) return undefined;
return { filename, contentType, data };
@@ -61,7 +63,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
}
function localIndexItem(item) {
if (!item?.attachment?.data) return item;
if (!item?.attachment?.data && !item?.attachment?.blob) return item;
return {
...item,
attachment: {
@@ -103,13 +105,13 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
async function hydrateForEdit(id) {
const item = read().find(candidate => candidate.id === id);
if (!item) return null;
if (!item.attachment?.stored || item.attachment.data) return { ...item };
if (!item.attachment?.stored || item.attachment.data || item.attachment.blob) return { ...item };
if (!backgroundSync?.get) {
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
}
const durable = await backgroundSync.get(id);
const attachment = captureAttachment(durable?.attachment);
if (!attachment?.data || durable?.operationId !== item.operationId) {
if ((!attachment?.data && !attachment?.blob) || durable?.operationId !== item.operationId) {
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
}
return { ...item, attachment };
@@ -196,6 +198,19 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
}
function attachmentMultipart(attachment) {
let blob = attachment?.blob;
if (!blob && attachment?.data) {
const binary = atob(String(attachment.data));
const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
blob = new Blob([bytes], { type: String(attachment.contentType || '') });
}
if (!blob) throw new Error('The saved screenshot is unavailable. Retry before sending.');
const form = new FormData();
form.append('file', blob, String(attachment.filename || 'screenshot'));
return form;
}
async function sendDirect(item, repository) {
let issue = item.deliveredIssue;
if (!issue) {
@@ -221,14 +236,10 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
{
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
Accept: 'application/json',
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
},
body: JSON.stringify({
filename: item.attachment.filename,
content_type: item.attachment.contentType,
data: item.attachment.data,
}),
body: attachmentMultipart(item.attachment),
},
);
markdown = String(uploaded?.markdown || '');