release: v4.8.8 file transfer consent fix
CodeQL Analysis / Analyze CodeQL (push) Has been cancelled
Deploy Application / deploy (push) Has been cancelled
Mirror to Codeberg / mirror (push) Has been cancelled
Mirror to PrivacyGuides / mirror (push) Has been cancelled

Complete the mandatory receiver-consent gate that was wired in the
backend but never connected to the UI callback chain:

- Add the missing onIncomingFileRequest (4th) callback to
  setFileTransferCallbacks in app.jsx — its absence caused
  handleFileTransferStart to auto-reject every incoming file.
- Remove independent callback registration from FileTransferComponent;
  the component was overwriting app-level callbacks on mount and
  nulling all four on unmount, silently breaking progress/received/
  error handlers whenever the panel was hidden.
- Lift pendingIncomingFiles state to the root component so consent
  prompts are shown regardless of panel visibility; auto-open the
  panel on incoming request.
- Add getReceivedFileObjectURL / revokeReceivedFileObjectURL on
  EnhancedSecureWebRTCManager for download buttons in the panel.
- Update file-transfer-ui-cleanup regression test to match the new
  single-owner callback architecture.
- All 14 tests pass; clean production build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
lockbitchat
2026-05-26 22:40:36 -04:00
parent 2468cb495e
commit 498d9a98e9
12 changed files with 200 additions and 209 deletions
+22
View File
@@ -1,5 +1,27 @@
# Changelog
## v4.8.8 — File transfer consent fix
This patch completes the mandatory receiver-consent gate for incoming file transfers and resolves a callback ownership conflict that caused every incoming file request to be silently auto-rejected.
### Fixed
- Wired up the missing fourth `onIncomingFileRequest` callback in the main `setFileTransferCallbacks` call. Without it, `handleFileTransferStart` always saw `null` for the consent handler and auto-rejected every incoming file silently.
- Removed independent callback registration from `FileTransferComponent`. The component was overwriting the application-level callbacks on mount and nulling all four on unmount, which destroyed the progress, received, and error handlers whenever the panel was hidden.
- Centralized incoming-consent state (`pendingIncomingFiles`) in the root application component so consent prompts appear regardless of whether the file-transfer panel is currently visible.
- Auto-opens the file-transfer panel when an incoming request arrives so the user sees the Accept / Reject prompt immediately.
- Added `getReceivedFileObjectURL` / `revokeReceivedFileObjectURL` helpers to `EnhancedSecureWebRTCManager` so the panel can offer a download button for completed transfers without relying on captured callback closures.
- Updated `file-transfer-ui-cleanup` regression test to match the new single-owner callback architecture.
### Security
No change to the cryptographic or transport-level security model. Sender chunks are still gated behind an explicit `file_transfer_response` from the receiver before any data is transmitted.
### Verification
- `npm test` — all 14 tests pass.
- `npm run build` — clean production build.
## v4.8.7 — WebRTC manual join reliability patch
This patch improves manual WebRTC setup across separate devices and restrictive local networks.
+33 -77
View File
@@ -15281,6 +15281,14 @@ var EnhancedSecureWebRTCManager = class _EnhancedSecureWebRTCManager {
if (!this.fileTransferSystem) return false;
return this.fileTransferSystem.rejectIncomingFile(fileId);
}
async getReceivedFileObjectURL(fileId) {
if (!this.fileTransferSystem) return null;
return this.fileTransferSystem.getObjectURL(fileId);
}
revokeReceivedFileObjectURL(url) {
if (!this.fileTransferSystem) return;
this.fileTransferSystem.revokeObjectURL(url);
}
// ============================================
// SESSION ACTIVATION HANDLING
// ============================================
@@ -18358,11 +18366,9 @@ function Roadmap() {
window.Roadmap = Roadmap;
// src/components/ui/FileTransfer.jsx
var FileTransferComponent = ({ webrtcManager, isConnected }) => {
var FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFiles = [], onIncomingDecision }) => {
const [dragOver, setDragOver] = React.useState(false);
const [transfers, setTransfers] = React.useState({ sending: [], receiving: [] });
const [readyFiles, setReadyFiles] = React.useState([]);
const [pendingIncomingFiles, setPendingIncomingFiles] = React.useState([]);
const fileInputRef = React.useRef(null);
React.useEffect(() => {
if (!isConnected || !webrtcManager) return;
@@ -18375,52 +18381,8 @@ var FileTransferComponent = ({ webrtcManager, isConnected }) => {
}, [isConnected, webrtcManager]);
React.useEffect(() => {
if (isConnected) return;
setReadyFiles([]);
setPendingIncomingFiles([]);
setTransfers({ sending: [], receiving: [] });
}, [isConnected]);
React.useEffect(() => {
if (!webrtcManager) return;
webrtcManager.setFileTransferCallbacks(
// Progress callback - ТОЛЬКО обновляем UI, НЕ отправляем в чат
(progress) => {
const currentTransfers = webrtcManager.getFileTransfers();
setTransfers(currentTransfers);
},
// File received callback - добавляем кнопку скачивания в UI
(fileData) => {
setReadyFiles((prev) => {
if (prev.some((f) => f.fileId === fileData.fileId)) return prev;
return [...prev, {
fileId: fileData.fileId,
fileName: fileData.fileName,
fileSize: fileData.fileSize,
mimeType: fileData.mimeType,
getBlob: fileData.getBlob,
getObjectURL: fileData.getObjectURL,
revokeObjectURL: fileData.revokeObjectURL
}];
});
const currentTransfers = webrtcManager.getFileTransfers();
setTransfers(currentTransfers);
},
// Error callback
(error) => {
const currentTransfers = webrtcManager.getFileTransfers();
setTransfers(currentTransfers);
},
// Incoming file request callback - user consent is mandatory
(fileRequest) => {
setPendingIncomingFiles((prev) => {
if (prev.some((file) => file.fileId === fileRequest.fileId)) return prev;
return [...prev, fileRequest];
});
}
);
return () => {
webrtcManager.setFileTransferCallbacks(null, null, null, null);
};
}, [webrtcManager]);
const handleFileSelect = async (files) => {
if (!isConnected || !webrtcManager) {
alert("\u0421\u043E\u0435\u0434\u0438\u043D\u0435\u043D\u0438\u0435 \u043D\u0435 \u0443\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D\u043E. \u0421\u043D\u0430\u0447\u0430\u043B\u0430 \u0443\u0441\u0442\u0430\u043D\u043E\u0432\u0438\u0442\u0435 \u0441\u043E\u0435\u0434\u0438\u043D\u0435\u043D\u0438\u0435.");
@@ -18517,16 +18479,10 @@ var FileTransferComponent = ({ webrtcManager, isConnected }) => {
}
};
const handleIncomingDecision = async (fileId, accepted) => {
try {
if (accepted) {
await webrtcManager.acceptIncomingFile(fileId);
} else {
await webrtcManager.rejectIncomingFile(fileId);
}
} finally {
setPendingIncomingFiles((prev) => prev.filter((file) => file.fileId !== fileId));
setTransfers(webrtcManager.getFileTransfers());
if (typeof onIncomingDecision === "function") {
await onIncomingDecision(fileId, accepted);
}
setTransfers(webrtcManager.getFileTransfers());
};
if (!isConnected) {
return React.createElement("div", {
@@ -18732,29 +18688,29 @@ var FileTransferComponent = ({ webrtcManager, isConnected }) => {
}, formatFileSize(transfer.fileSize))
]),
React.createElement("div", { key: "actions", className: "flex items-center space-x-2" }, [
(() => {
const rf = readyFiles.find((f) => f.fileId === transfer.fileId);
if (!rf || transfer.status !== "completed") return null;
return React.createElement("button", {
key: "download",
className: "text-green-400 hover:text-green-300 text-xs flex items-center",
onClick: async () => {
try {
const url = await rf.getObjectURL();
const a = document.createElement("a");
a.href = url;
a.download = rf.fileName || "file";
a.click();
rf.revokeObjectURL(url);
} catch (e) {
alert(e.message || "This file is no longer available for download.");
transfer.status === "completed" ? React.createElement("button", {
key: "download",
className: "text-green-400 hover:text-green-300 text-xs flex items-center",
onClick: async () => {
try {
const url = await webrtcManager.getReceivedFileObjectURL(transfer.fileId);
if (!url) {
alert("This file is no longer available for download.");
return;
}
const a = document.createElement("a");
a.href = url;
a.download = transfer.fileName || "file";
a.click();
setTimeout(() => webrtcManager.revokeReceivedFileObjectURL(url), 1e4);
} catch (e) {
alert(e.message || "This file is no longer available for download.");
}
}, [
React.createElement("i", { key: "i", className: "fas fa-download mr-1" }),
"Download"
]);
})(),
}
}, [
React.createElement("i", { key: "i", className: "fas fa-download mr-1" }),
"Download"
]) : null,
React.createElement("button", {
key: "cancel",
onClick: () => webrtcManager.cancelFileTransfer(transfer.fileId),
+2 -2
View File
File diff suppressed because one or more lines are too long
Vendored
+34 -3
View File
@@ -1186,10 +1186,17 @@ var EnhancedChatInterface = ({
isVerified,
chatMessagesRef,
scrollToBottom,
webrtcManager
webrtcManager,
pendingIncomingFiles = [],
onIncomingDecision
}) => {
const [showScrollButton, setShowScrollButton] = React.useState(false);
const [showFileTransfer, setShowFileTransfer] = React.useState(false);
React.useEffect(() => {
if (pendingIncomingFiles.length > 0) {
setShowFileTransfer(true);
}
}, [pendingIncomingFiles.length]);
React.useEffect(() => {
if (chatMessagesRef.current && messages.length > 0) {
const { scrollTop, scrollHeight, clientHeight } = chatMessagesRef.current;
@@ -1398,7 +1405,9 @@ var EnhancedChatInterface = ({
className: "p-4 text-center text-red-400"
}, "FileTransferComponent not loaded")), {
webrtcManager,
isConnected: isFileTransferReady()
isConnected: isFileTransferReady(),
pendingIncomingFiles,
onIncomingDecision
})
]
)
@@ -1494,6 +1503,7 @@ var EnhancedSecureP2PChat = () => {
const [remoteVerificationConfirmed, setRemoteVerificationConfirmed] = React.useState(false);
const [bothVerificationsConfirmed, setBothVerificationsConfirmed] = React.useState(false);
const [pendingSession, setPendingSession] = React.useState(null);
const [pendingIncomingFiles, setPendingIncomingFiles] = React.useState([]);
const [connectionState, setConnectionState] = React.useState({
status: "disconnected",
hasActiveAnswer: false,
@@ -1899,6 +1909,13 @@ var EnhancedSecureP2PChat = () => {
} else {
addMessageWithAutoScroll(` File transfer error: ${error}`, "system");
}
},
// Incoming file request callback — receiver must explicitly accept before any data is sent
(fileRequest) => {
setPendingIncomingFiles((prev) => {
if (prev.some((f) => f.fileId === fileRequest.fileId)) return prev;
return [...prev, fileRequest];
});
}
);
}
@@ -3044,6 +3061,17 @@ var EnhancedSecureP2PChat = () => {
setPendingSession(null);
document.dispatchEvent(new CustomEvent("peer-disconnect"));
};
const handleIncomingDecision = React.useCallback(async (fileId, accepted) => {
try {
if (accepted) {
await webrtcManagerRef.current?.acceptIncomingFile(fileId);
} else {
await webrtcManagerRef.current?.rejectIncomingFile(fileId);
}
} finally {
setPendingIncomingFiles((prev) => prev.filter((f) => f.fileId !== fileId));
}
}, []);
const handleDisconnect = () => {
try {
setSessionTimeLeft(0);
@@ -3079,6 +3107,7 @@ var EnhancedSecureP2PChat = () => {
setShowQRScanner(false);
setShowQRScannerModal(false);
setMessages([]);
setPendingIncomingFiles([]);
if (typeof console.clear === "function") {
console.clear();
}
@@ -3236,7 +3265,9 @@ var EnhancedSecureP2PChat = () => {
isVerified,
chatMessagesRef,
scrollToBottom,
webrtcManager: webrtcManagerRef.current
webrtcManager: webrtcManagerRef.current,
pendingIncomingFiles,
onIncomingDecision: handleIncomingDecision
});
})() : React.createElement(EnhancedConnectionSetup, {
onCreateOffer: handleCreateOffer,
+2 -2
View File
File diff suppressed because one or more lines are too long
+4 -4
View File
@@ -148,13 +148,13 @@
<!-- Update Manager - система принудительного обновления -->
<script src="src/utils/updateManager.js"></script>
<script type="module" src="src/components/UpdateChecker.jsx"></script>
<script type="module" src="dist/qr-local.js?v=1779198538664"></script>
<script type="module" src="src/components/QRScanner.js?v=1779198538664"></script>
<script type="module" src="dist/qr-local.js?v=1779848383991"></script>
<script type="module" src="src/components/QRScanner.js?v=1779848383991"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="dist/app-boot.js?v=1779198538664"></script>
<script type="module" src="dist/app.js?v=1779198538664"></script>
<script type="module" src="dist/app-boot.js?v=1779848383991"></script>
<script type="module" src="dist/app.js?v=1779848383991"></script>
<script src="src/scripts/pwa-register.js"></script>
<script src="./src/pwa/install-prompt.js" type="module"></script>
+7 -7
View File
@@ -1,10 +1,10 @@
{
"version": "1779198538664",
"buildVersion": "1779198538664",
"appVersion": "4.8.7",
"buildTime": "2026-05-19T13:48:58.703Z",
"buildId": "1779198538664-1cc8732",
"gitHash": "1cc8732",
"version": "1779848383991",
"buildVersion": "1779848383991",
"appVersion": "4.8.8",
"buildTime": "2026-05-27T02:19:44.033Z",
"buildId": "1779848383991-2468cb4",
"gitHash": "2468cb4",
"generated": true,
"generatedAt": "2026-05-19T13:48:58.704Z"
"generatedAt": "2026-05-27T02:19:44.035Z"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "securebit-chat",
"version": "4.8.7",
"version": "4.8.8",
"description": "Secure P2P Communication Application with End-to-End Encryption",
"main": "index.html",
"scripts": {
+39 -3
View File
@@ -1241,11 +1241,20 @@ import { installDebugWindowHooks } from './utils/debugWindowHooks.js';
isVerified,
chatMessagesRef,
scrollToBottom,
webrtcManager
webrtcManager,
pendingIncomingFiles = [],
onIncomingDecision
}) => {
const [showScrollButton, setShowScrollButton] = React.useState(false);
const [showFileTransfer, setShowFileTransfer] = React.useState(false);
// Auto-open the file transfer panel when an incoming request arrives
React.useEffect(() => {
if (pendingIncomingFiles.length > 0) {
setShowFileTransfer(true);
}
}, [pendingIncomingFiles.length]);
React.useEffect(() => {
if (chatMessagesRef.current && messages.length > 0) {
const { scrollTop, scrollHeight, clientHeight } = chatMessagesRef.current;
@@ -1475,7 +1484,9 @@ import { installDebugWindowHooks } from './utils/debugWindowHooks.js';
}, 'FileTransferComponent not loaded')
), {
webrtcManager: webrtcManager,
isConnected: isFileTransferReady()
isConnected: isFileTransferReady(),
pendingIncomingFiles: pendingIncomingFiles,
onIncomingDecision: onIncomingDecision
})
]
)
@@ -1585,6 +1596,8 @@ import { installDebugWindowHooks } from './utils/debugWindowHooks.js';
// All security features are enabled by default - no payment required
const [pendingIncomingFiles, setPendingIncomingFiles] = React.useState([]);
// ============================================
@@ -2111,6 +2124,14 @@ import { installDebugWindowHooks } from './utils/debugWindowHooks.js';
} else {
addMessageWithAutoScroll(` File transfer error: ${error}`, 'system');
}
},
// Incoming file request callback receiver must explicitly accept before any data is sent
(fileRequest) => {
setPendingIncomingFiles(prev => {
if (prev.some(f => f.fileId === fileRequest.fileId)) return prev;
return [...prev, fileRequest];
});
}
);
}
@@ -3464,6 +3485,18 @@ import { installDebugWindowHooks } from './utils/debugWindowHooks.js';
// Session manager removed - all features enabled by default
};
const handleIncomingDecision = React.useCallback(async (fileId, accepted) => {
try {
if (accepted) {
await webrtcManagerRef.current?.acceptIncomingFile(fileId);
} else {
await webrtcManagerRef.current?.rejectIncomingFile(fileId);
}
} finally {
setPendingIncomingFiles(prev => prev.filter(f => f.fileId !== fileId));
}
}, []);
const handleDisconnect = () => {
try {
setSessionTimeLeft(0);
@@ -3513,6 +3546,7 @@ import { installDebugWindowHooks } from './utils/debugWindowHooks.js';
// Clear messages
setMessages([]);
setPendingIncomingFiles([]);
// Clear console
if (typeof console.clear === 'function') {
@@ -3705,7 +3739,9 @@ import { installDebugWindowHooks } from './utils/debugWindowHooks.js';
isVerified: isVerified,
chatMessagesRef: chatMessagesRef,
scrollToBottom: scrollToBottom,
webrtcManager: webrtcManagerRef.current
webrtcManager: webrtcManagerRef.current,
pendingIncomingFiles: pendingIncomingFiles,
onIncomingDecision: handleIncomingDecision
});
})()
: React.createElement(EnhancedConnectionSetup, {
+25 -97
View File
@@ -1,12 +1,10 @@
// File Transfer Component for Chat Interface - Fixed Version
const FileTransferComponent = ({ webrtcManager, isConnected }) => {
const FileTransferComponent = ({ webrtcManager, isConnected, pendingIncomingFiles = [], onIncomingDecision }) => {
const [dragOver, setDragOver] = React.useState(false);
const [transfers, setTransfers] = React.useState({ sending: [], receiving: [] });
const [readyFiles, setReadyFiles] = React.useState([]); // файлы, готовые к скачиванию
const [pendingIncomingFiles, setPendingIncomingFiles] = React.useState([]);
const fileInputRef = React.useRef(null);
// Update transfers periodically
// Update transfers periodically via polling no callback registration needed here
React.useEffect(() => {
if (!isConnected || !webrtcManager) return;
@@ -19,73 +17,12 @@ const FileTransferComponent = ({ webrtcManager, isConnected }) => {
return () => clearInterval(interval);
}, [isConnected, webrtcManager]);
// Clear session-local UI state when the connection ends so reconnect starts clean.
// Clear transfers UI when connection drops
React.useEffect(() => {
if (isConnected) return;
setReadyFiles([]);
setPendingIncomingFiles([]);
setTransfers({ sending: [], receiving: [] });
}, [isConnected]);
// Setup file transfer callbacks - ИСПРАВЛЕНИЕ: НЕ отправляем промежуточные сообщения в чат
React.useEffect(() => {
if (!webrtcManager) return;
webrtcManager.setFileTransferCallbacks(
// Progress callback - ТОЛЬКО обновляем UI, НЕ отправляем в чат
(progress) => {
// Обновляем только локальное состояние
const currentTransfers = webrtcManager.getFileTransfers();
setTransfers(currentTransfers);
// НЕ отправляем сообщения в чат!
},
// File received callback - добавляем кнопку скачивания в UI
(fileData) => {
// Добавляем в список готовых к скачиванию
setReadyFiles(prev => {
// избегаем дублей по fileId
if (prev.some(f => f.fileId === fileData.fileId)) return prev;
return [...prev, {
fileId: fileData.fileId,
fileName: fileData.fileName,
fileSize: fileData.fileSize,
mimeType: fileData.mimeType,
getBlob: fileData.getBlob,
getObjectURL: fileData.getObjectURL,
revokeObjectURL: fileData.revokeObjectURL
}];
});
// Обновляем список активных передач
const currentTransfers = webrtcManager.getFileTransfers();
setTransfers(currentTransfers);
},
// Error callback
(error) => {
const currentTransfers = webrtcManager.getFileTransfers();
setTransfers(currentTransfers);
// ИСПРАВЛЕНИЕ: НЕ дублируем сообщения об ошибках
// Уведомления об ошибках уже отправляются в WebRTC менеджере
},
// Incoming file request callback - user consent is mandatory
(fileRequest) => {
setPendingIncomingFiles(prev => {
if (prev.some(file => file.fileId === fileRequest.fileId)) return prev;
return [...prev, fileRequest];
});
}
);
return () => {
webrtcManager.setFileTransferCallbacks(null, null, null, null);
};
}, [webrtcManager]);
const handleFileSelect = async (files) => {
if (!isConnected || !webrtcManager) {
alert('Соединение не установлено. Сначала установите соединение.');
@@ -199,16 +136,10 @@ const FileTransferComponent = ({ webrtcManager, isConnected }) => {
};
const handleIncomingDecision = async (fileId, accepted) => {
try {
if (accepted) {
await webrtcManager.acceptIncomingFile(fileId);
} else {
await webrtcManager.rejectIncomingFile(fileId);
}
} finally {
setPendingIncomingFiles(prev => prev.filter(file => file.fileId !== fileId));
setTransfers(webrtcManager.getFileTransfers());
if (typeof onIncomingDecision === 'function') {
await onIncomingDecision(fileId, accepted);
}
setTransfers(webrtcManager.getFileTransfers());
};
if (!isConnected) {
@@ -424,29 +355,26 @@ const FileTransferComponent = ({ webrtcManager, isConnected }) => {
}, formatFileSize(transfer.fileSize))
]),
React.createElement('div', { key: 'actions', className: 'flex items-center space-x-2' }, [
(() => {
const rf = readyFiles.find(f => f.fileId === transfer.fileId);
if (!rf || transfer.status !== 'completed') return null;
return React.createElement('button', {
key: 'download',
className: 'text-green-400 hover:text-green-300 text-xs flex items-center',
onClick: async () => {
try {
const url = await rf.getObjectURL();
const a = document.createElement('a');
a.href = url;
a.download = rf.fileName || 'file';
a.click();
rf.revokeObjectURL(url);
} catch (e) {
alert(e.message || 'This file is no longer available for download.');
}
transfer.status === 'completed' ? React.createElement('button', {
key: 'download',
className: 'text-green-400 hover:text-green-300 text-xs flex items-center',
onClick: async () => {
try {
const url = await webrtcManager.getReceivedFileObjectURL(transfer.fileId);
if (!url) { alert('This file is no longer available for download.'); return; }
const a = document.createElement('a');
a.href = url;
a.download = transfer.fileName || 'file';
a.click();
setTimeout(() => webrtcManager.revokeReceivedFileObjectURL(url), 10000);
} catch (e) {
alert(e.message || 'This file is no longer available for download.');
}
}, [
React.createElement('i', { key: 'i', className: 'fas fa-download mr-1' }),
'Download'
]);
})(),
}
}, [
React.createElement('i', { key: 'i', className: 'fas fa-download mr-1' }),
'Download'
]) : null,
React.createElement('button', {
key: 'cancel',
onClick: () => webrtcManager.cancelFileTransfer(transfer.fileId),
@@ -12389,6 +12389,16 @@ async processMessage(data) {
return this.fileTransferSystem.rejectIncomingFile(fileId);
}
async getReceivedFileObjectURL(fileId) {
if (!this.fileTransferSystem) return null;
return this.fileTransferSystem.getObjectURL(fileId);
}
revokeReceivedFileObjectURL(url) {
if (!this.fileTransferSystem) return;
this.fileTransferSystem.revokeObjectURL(url);
}
// ============================================
// SESSION ACTIVATION HANDLING
// ============================================
+16 -8
View File
@@ -58,18 +58,26 @@ const manager = {
isVerified: false
};
context.window.FileTransferComponent({ webrtcManager: manager, isConnected: false });
// Component no longer manages callbacks — consent is handled by the parent (app.jsx).
// pendingIncomingFiles and onIncomingDecision are passed as props.
context.window.FileTransferComponent({ webrtcManager: manager, isConnected: false, pendingIncomingFiles: [], onIncomingDecision: null });
const cleanups = effects.map(effect => effect()).filter(Boolean);
assert.ok(setterCalls.some(call => call.index === 2 && Array.isArray(call.value) && call.value.length === 0));
assert.ok(setterCalls.some(call => call.index === 3 && Array.isArray(call.value) && call.value.length === 0));
// State index 0 = dragOver, index 1 = transfers.
// Transfers state should be reset to empty on disconnect.
assert.ok(setterCalls.some(call => call.index === 1 && call.value.sending.length === 0 && call.value.receiving.length === 0));
// Component must NOT call setFileTransferCallbacks — that is the parent's responsibility.
assert.equal(callbackCalls.length, 0, 'FileTransferComponent must not register its own callbacks');
// Cleanup effects must not null-out the manager's callbacks either.
cleanups.forEach(cleanup => cleanup());
assert.deepEqual(callbackCalls.at(-1), [null, null, null, null]);
assert.equal(manager.fileTransferSystem.onProgress, null);
assert.equal(manager.fileTransferSystem.onFileReceived, null);
assert.equal(manager.fileTransferSystem.onError, null);
assert.equal(manager.fileTransferSystem.onIncomingFileRequest, null);
assert.equal(callbackCalls.length, 0, 'cleanup must not call setFileTransferCallbacks');
// fileTransferSystem callbacks are untouched by the component.
assert.equal(typeof manager.fileTransferSystem.onProgress, 'function');
assert.equal(typeof manager.fileTransferSystem.onFileReceived, 'function');
assert.equal(typeof manager.fileTransferSystem.onError, 'function');
assert.equal(typeof manager.fileTransferSystem.onIncomingFileRequest, 'function');
console.log('File transfer UI cleanup tests passed');