Rewrite FileDigester to tidy up code, be able to delete files in OPFS onPageHide and on abort of file transfer

This commit is contained in:
schlagmichdoch 2024-07-14 18:04:03 +02:00
parent fa86212139
commit 76c47c9623
5 changed files with 346 additions and 127 deletions

View file

@ -25,6 +25,11 @@ class BrowserTabsConnector {
: false; : false;
} }
static isOnlyTab() {
let peerIdsBrowser = JSON.parse(localStorage.getItem('peer_ids_browser'));
return peerIdsBrowser.length <= 1;
}
static async addPeerIdToLocalStorage() { static async addPeerIdToLocalStorage() {
const peerId = sessionStorage.getItem('peer_id'); const peerId = sessionStorage.getItem('peer_id');
if (!peerId) return false; if (!peerId) return false;

View file

@ -352,7 +352,7 @@ class Peer {
clearInterval(this._updateStatusTextInterval); clearInterval(this._updateStatusTextInterval);
this._transferStatusInterval = null; this._updateStatusTextInterval = null;
this._bytesTotal = 0; this._bytesTotal = 0;
this._bytesReceivedFiles = 0; this._bytesReceivedFiles = 0;
this._timeStartTransferComplete = null; this._timeStartTransferComplete = null;
@ -366,9 +366,13 @@ class Peer {
// tidy up receiver // tidy up receiver
this._pendingRequest = null; this._pendingRequest = null;
this._acceptedRequest = null; this._acceptedRequest = null;
this._digester = null;
this._filesReceived = []; this._filesReceived = [];
if (this._digester) {
this._digester.cleanUp();
this._digester = null;
}
// disable NoSleep if idle // disable NoSleep if idle
Events.fire('evaluate-no-sleep'); Events.fire('evaluate-no-sleep');
} }
@ -625,6 +629,11 @@ class Peer {
_abortTransfer() { _abortTransfer() {
Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'error'}); Events.fire('set-progress', {peerId: this._peerId, progress: 0, status: 'error'});
if (this._digester) {
this._digester.abort();
}
this._reset(); this._reset();
} }
@ -713,7 +722,7 @@ class Peer {
for (let i = 0; i < files.length; i++) { for (let i = 0; i < files.length; i++) {
header.push({ header.push({
name: files[i].name, displayName: files[i].name,
mime: files[i].type, mime: files[i].type,
size: files[i].size size: files[i].size
}); });
@ -796,7 +805,7 @@ class Peer {
this._sendMessage({ this._sendMessage({
type: 'transfer-header', type: 'transfer-header',
size: file.size, size: file.size,
name: file.name, displayName: file.name,
mime: file.type mime: file.type
}); });
} }
@ -866,8 +875,12 @@ class Peer {
return; return;
} }
this.fileDigesterWorkerSupported = await SWFileDigester.isSupported();
Logger.debug('Digesting files via service workers is', this.fileDigesterWorkerSupported ? 'supported' : 'NOT supported');
// Check if each file must be loaded into RAM completely. This might lead to a page crash (Memory limit iOS Safari: ~380 MB) // Check if each file must be loaded into RAM completely. This might lead to a page crash (Memory limit iOS Safari: ~380 MB)
if (!(await FileDigesterWorker.isSupported())) { if (!this.fileDigesterWorkerSupported) {
Logger.warn('Big file transfers might exceed the RAM of the receiver. Use a secure context (https) and do not use private tabs to prevent this.'); Logger.warn('Big file transfers might exceed the RAM of the receiver. Use a secure context (https) and do not use private tabs to prevent this.');
// Check if page will crash on iOS // Check if page will crash on iOS
@ -952,8 +965,23 @@ class Peer {
} }
_addFileDigester(header) { _addFileDigester(header) {
this._digester = new FileDigester({size: header.size, name: header.name, mime: header.mime}, this._digester = this.fileDigesterWorkerSupported
fileBlob => this._fileReceived(fileBlob), ? new FileDigesterViaWorker(
{
size: header.size,
name: header.displayName,
mime: header.mime
},
file => this._fileReceived(file),
bytesReceived => this._sendReceiveConfirmation(bytesReceived)
)
: new FileDigesterViaBuffer(
{
size: header.size,
name: header.displayName,
mime: header.mime
},
file => this._fileReceived(file),
bytesReceived => this._sendReceiveConfirmation(bytesReceived) bytesReceived => this._sendReceiveConfirmation(bytesReceived)
); );
} }
@ -1025,7 +1053,7 @@ class Peer {
const sameSize = header.size === acceptedHeader.size; const sameSize = header.size === acceptedHeader.size;
const sameType = header.mime === acceptedHeader.mime; const sameType = header.mime === acceptedHeader.mime;
const sameName = header.name === acceptedHeader.name; const sameName = header.displayName === acceptedHeader.displayName;
return sameSize && sameType && sameName; return sameSize && sameType && sameName;
} }
@ -1045,7 +1073,7 @@ class Peer {
Logger.log(`File received.\n\nSize: ${size} MB\tDuration: ${duration} s\tSpeed: ${speed} MB/s`); Logger.log(`File received.\n\nSize: ${size} MB\tDuration: ${duration} s\tSpeed: ${speed} MB/s`);
// include for compatibility with 'Snapdrop & PairDrop for Android' app // include for compatibility with 'Snapdrop & PairDrop for Android' app
Events.fire('file-received', file); Events.fire('file-received', {name: file.displayName, size: file.size});
this._filesReceived.push(file); this._filesReceived.push(file);
@ -1605,6 +1633,9 @@ class PeersManager {
Events.on('ws-config', e => this._onWsConfig(e.detail)); Events.on('ws-config', e => this._onWsConfig(e.detail));
Events.on('evaluate-no-sleep', _ => this._onEvaluateNoSleep()); Events.on('evaluate-no-sleep', _ => this._onEvaluateNoSleep());
// clean up on page hide
Events.on('pagehide', _ => this._onPageHide());
} }
_onWsConfig(wsConfig) { _onWsConfig(wsConfig) {
@ -1625,6 +1656,13 @@ class PeersManager {
NoSleepUI.disable(); NoSleepUI.disable();
} }
_onPageHide() {
// Clear OPFS directory ONLY if this is the last PairDrop Browser tab
if (!BrowserTabsConnector.isOnlyTab()) return;
SWFileDigester.clearDirectory();
}
_refreshPeer(isCaller, peerId, roomType, roomId) { _refreshPeer(isCaller, peerId, roomType, roomId) {
const peer = this.peers[peerId]; const peer = this.peers[peerId];
const roomTypesDiffer = Object.keys(peer._roomIds)[0] !== roomType; const roomTypesDiffer = Object.keys(peer._roomIds)[0] !== roomType;
@ -1947,7 +1985,6 @@ class FileChunkerWS extends FileChunker {
class FileDigester { class FileDigester {
constructor(meta, fileCompleteCallback, sendReceiveConfirmationCallback) { constructor(meta, fileCompleteCallback, sendReceiveConfirmationCallback) {
this._buffer = [];
this._bytesReceived = 0; this._bytesReceived = 0;
this._bytesReceivedSinceLastTime = 0; this._bytesReceivedSinceLastTime = 0;
this._maxBytesWithoutConfirmation = 1048576; // 1 MB this._maxBytesWithoutConfirmation = 1048576; // 1 MB
@ -1958,8 +1995,9 @@ class FileDigester {
this._sendReceiveConfimationCallback = sendReceiveConfirmationCallback; this._sendReceiveConfimationCallback = sendReceiveConfirmationCallback;
} }
unchunk(chunk) { unchunk(chunk) {}
this._buffer.push(chunk);
evaluateChunkSize(chunk) {
this._bytesReceived += chunk.byteLength; this._bytesReceived += chunk.byteLength;
this._bytesReceivedSinceLastTime += chunk.byteLength; this._bytesReceivedSinceLastTime += chunk.byteLength;
@ -1972,70 +2010,152 @@ class FileDigester {
this._sendReceiveConfimationCallback(this._bytesReceived); this._sendReceiveConfimationCallback(this._bytesReceived);
this._bytesReceivedSinceLastTime = 0; this._bytesReceivedSinceLastTime = 0;
} }
// File not completely received -> Wait for next chunk.
if (this._bytesReceived < this._size) return;
// We are done receiving. Preferably use a file worker to process the file to prevent exceeding of available RAM
FileDigesterWorker.isSupported()
.then(supported => {
if (!supported) {
this.processFileViaMemory();
return;
} }
this.processFileViaWorker();
}); isFileReceivedCompletely() {
return this._bytesReceived >= this._size;
}
cleanUp() {}
abort() {}
}
class FileDigesterViaBuffer extends FileDigester {
constructor(meta, fileCompleteCallback, sendReceiveConfirmationCallback) {
super(meta, fileCompleteCallback, sendReceiveConfirmationCallback);
this._buffer = [];
}
unchunk(chunk) {
this._buffer.push(chunk);
this.evaluateChunkSize(chunk);
// If file is not completely received -> Wait for next chunk.
if (!this.isFileReceivedCompletely()) return;
this.processFileViaMemory();
} }
processFileViaMemory() { processFileViaMemory() {
// Loads complete file into RAM which might lead to a page crash (Memory limit iOS Safari: ~380 MB) // Loads complete file into RAM which might lead to a page crash (Memory limit iOS Safari: ~380 MB)
const file = new File(this._buffer, this._name, { const file = new File(
this._buffer,
this._name,
{
type: this._mime, type: this._mime,
lastModified: new Date().getTime() lastModified: new Date().getTime()
}) }
);
file.displayName = this._name
this._fileCompleteCallback(file); this._fileCompleteCallback(file);
} }
processFileViaWorker() { cleanUp() {
const fileDigesterWorker = new FileDigesterWorker(); this._buffer = [];
fileDigesterWorker.digestFileBuffer(this._buffer, this._name) }
.then(file => {
this._fileCompleteCallback(file); abort() {
}) this.cleanUp();
.catch(reason => {
Logger.warn(reason);
this.processFileViaWorker();
})
} }
} }
class FileDigesterWorker { class FileDigesterViaWorker extends FileDigester {
constructor(meta, fileCompleteCallback, sendReceiveConfirmationCallback) {
super(meta, fileCompleteCallback, sendReceiveConfirmationCallback);
this._fileDigesterWorker = new SWFileDigester();
}
constructor() { unchunk(chunk) {
this._fileDigesterWorker
.nextChunk(chunk, this._bytesReceived)
.then(_ => {
this.evaluateChunkSize(chunk);
// If file is not completely received -> Wait for next chunk.
if (!this.isFileReceivedCompletely()) return;
this.processFileViaWorker();
});
}
processFileViaWorker() {
this._fileDigesterWorker
.getFile()
.then(file => {
// Save id and displayName to file to be able to truncate file later
file.id = file.name;
file.displayName = this._name;
this._fileCompleteCallback(file);
})
.catch(e => {
Logger.error("Error in SWFileDigester:", e);
this.cleanUp();
});
}
cleanUp() {
this._fileDigesterWorker.cleanUp();
}
abort() {
// delete and clean up (included in deletion)
this._fileDigesterWorker.deleteFile().then((id) => {
Logger.debug("File deleted after abort:", id);
});
}
}
class SWFileDigester {
static fileWorkers = [];
constructor(id = null) {
// Use service worker to prevent loading the complete file into RAM // Use service worker to prevent loading the complete file into RAM
this.fileWorker = new Worker("scripts/sw-file-digester.js"); // Uses origin private file system (OPFS) as storage endpoint
if (!id) {
// Generate random uuid to save file on disk
// Create only one service worker per file to prevent problems with accessHandles
id = generateUUID();
SWFileDigester.fileWorkers[id] = new Worker("scripts/sw-file-digester.js");
}
this.id = id;
this.fileWorker = SWFileDigester.fileWorkers[id];
this.fileWorker.onmessage = (e) => { this.fileWorker.onmessage = (e) => {
switch (e.data.type) { switch (e.data.type) {
case "support": case "support":
this.onSupport(e.data.supported); this.onSupport(e.data.supported);
break; break;
case "part": case "chunk-written":
this.onPart(e.data.part); this.onChunkWritten(e.data.offset);
break; break;
case "file": case "file":
this.onFile(e.data.file); this.onFile(e.data.file);
break; break;
case "file-deleted": case "file-deleted":
this.onFileDeleted(); this.onFileDeleted(e.data.id);
break; break;
case "error": case "error":
this.onError(e.data.error); this.onError(e.data.error);
break; break;
case "directory-cleared":
this.onDirectoryCleared();
break;
} }
} }
} }
onError(error) {
// an error occurred.
Logger.error(error);
}
static isSupported() { static isSupported() {
// Check if web worker is supported and supports specific functions // Check if web worker is supported and supports specific functions
return new Promise(async resolve => { return new Promise(async resolve => {
@ -2044,7 +2164,7 @@ class FileDigesterWorker {
return; return;
} }
const fileDigesterWorker = new FileDigesterWorker(); const fileDigesterWorker = new SWFileDigester();
resolve(await fileDigesterWorker.checkSupport()); resolve(await fileDigesterWorker.checkSupport());
@ -2068,75 +2188,88 @@ class FileDigesterWorker {
this.resolveSupport = null; this.resolveSupport = null;
} }
digestFileBuffer(buffer, fileName) { nextChunk(chunk, offset) {
return new Promise((resolve, reject) => { return new Promise(resolve => {
this.resolveFile = resolve; this.digestChunk(chunk, offset);
this.rejectFile = reject; resolve();
});
this.i = 0;
this.offset = 0;
this.buffer = buffer;
this.fileName = fileName;
this.sendPart(this.buffer[0], 0);
})
} }
digestChunk(chunk, offset) {
sendPart(buffer, offset) {
this.fileWorker.postMessage({ this.fileWorker.postMessage({
type: "part", type: "chunk",
name: this.fileName, id: this.id,
buffer: buffer, chunk: chunk,
offset: offset offset: offset
}); });
} }
getFile() { onChunkWritten(chunkOffset) {
this.fileWorker.postMessage({ Logger.debug("Chunk written at offset", chunkOffset);
type: "get-file",
name: this.fileName,
});
} }
deleteFile() { getFile() {
return new Promise(resolve => {
this.resolveFile = resolve;
this.fileWorker.postMessage({ this.fileWorker.postMessage({
type: "delete-file", type: "get-file",
name: this.fileName id: this.id,
});
}) })
} }
onPart(part) { async getFileById(id) {
if (this.i < this.buffer.length - 1) { const swFileDigester = new SWFileDigester(id);
// process next chunk return await swFileDigester.getFile();
this.offset += part.byteLength;
this.i++;
this.sendPart(this.buffer[this.i], this.offset);
return;
}
// File processing complete -> retrieve completed file
this.getFile();
} }
onFile(file) { onFile(file) {
this.buffer = [];
this.resolveFile(file); this.resolveFile(file);
this.deleteFile();
} }
onFileDeleted() { deleteFile() {
return new Promise(resolve => {
this.resolveDeletion = resolve;
this.fileWorker.postMessage({
type: "delete-file",
id: this.id
});
});
}
static async deleteFileById(id) {
const swFileDigester = new SWFileDigester(id);
return await swFileDigester.deleteFile();
}
cleanUp() {
// terminate service worker
this.fileWorker.terminate();
delete SWFileDigester.fileWorkers[this.id];
}
onFileDeleted(id) {
// File Digestion complete -> Tidy up // File Digestion complete -> Tidy up
this.fileWorker.terminate(); Logger.debug("File deleted:", id);
this.resolveDeletion(id);
this.cleanUp();
} }
onError(error) { static clearDirectory() {
// an error occurred. for (let i = 0; i < SWFileDigester.fileWorkers.length; i++) {
Logger.error(error); SWFileDigester.fileWorkers[i].terminate();
}
SWFileDigester.fileWorkers = [];
// Use memory method instead and terminate service worker. const swFileDigester = new SWFileDigester();
this.fileWorker.terminate(); swFileDigester.fileWorker.postMessage({
this.rejectFile("Failed to process file via service-worker. Do not use Firefox private mode to prevent this."); type: "clear-directory",
});
}
onDirectoryCleared() {
Logger.debug("All files on OPFS truncated.");
this.cleanUp();
} }
} }

View file

@ -1,70 +1,104 @@
self.accessHandle = undefined;
self.messageQueue = [];
self.busy = false;
self.addEventListener('message', async e => { self.addEventListener('message', async e => {
// Put message into queue if busy
if (self.busy) {
self.messageQueue.push(e.data);
return;
}
await digestMessage(e.data);
});
async function digestMessage(message) {
self.busy = true;
try { try {
switch (e.data.type) { switch (message.type) {
case "check-support": case "check-support":
await checkSupport(); await checkSupport();
break; break;
case "part": case "chunk":
await onPart(e.data.name, e.data.buffer, e.data.offset); await onChunk(message.id, message.chunk, message.offset);
break; break;
case "get-file": case "get-file":
await onGetFile(e.data.name); await onGetFile(message.id);
break; break;
case "delete-file": case "delete-file":
await onDeleteFile(e.data.name); await onDeleteFile(message.id);
break;
case "clear-directory":
await onClearDirectory();
break; break;
} }
} }
catch (e) { catch (e) {
self.postMessage({type: "error", error: e}); self.postMessage({type: "error", error: e});
} }
})
// message is digested. Digest next message.
await messageDigested();
}
async function messageDigested() {
if (!self.messageQueue.length) {
// no chunk in queue -> set flag to false and stop
this.busy = false;
return;
}
// Digest next message in queue
await this.digestMessage(self.messageQueue.pop());
}
async function checkSupport() { async function checkSupport() {
try { try {
await getAccessHandle("test.txt"); const accessHandle = await getAccessHandle("test");
self.postMessage({type: "support", supported: true}); self.postMessage({type: "support", supported: true});
accessHandle.close();
} }
catch (e) { catch (e) {
self.postMessage({type: "support", supported: false}); self.postMessage({type: "support", supported: false});
} }
} }
async function getFileHandle(fileName) { async function getFileHandle(id) {
const root = await navigator.storage.getDirectory(); const dirHandle = await navigator.storage.getDirectory();
return await root.getFileHandle(fileName, {create: true}); return await dirHandle.getFileHandle(id, {create: true});
} }
async function getAccessHandle(fileName) { async function getAccessHandle(id) {
const fileHandle = await getFileHandle(fileName); const fileHandle = await getFileHandle(id);
if (!self.accessHandle) {
// Create FileSystemSyncAccessHandle on the file. // Create FileSystemSyncAccessHandle on the file.
return await fileHandle.createSyncAccessHandle(); self.accessHandle = await fileHandle.createSyncAccessHandle();
}
return self.accessHandle;
} }
async function onPart(fileName, buffer, offset) { async function onChunk(id, chunk, offset) {
const accessHandle = await getAccessHandle(fileName); const accessHandle = await getAccessHandle(id);
// Write the message to the end of the file. // Write the message to the end of the file.
let encodedMessage = new DataView(buffer); let encodedMessage = new DataView(chunk);
accessHandle.write(encodedMessage, { at: offset }); accessHandle.write(encodedMessage, { at: offset });
// Always close FileSystemSyncAccessHandle if done. self.postMessage({type: "chunk-written", offset: offset});
accessHandle.close(); accessHandle.close();
self.postMessage({type: "part", part: encodedMessage});
encodedMessage = null;
} }
async function onGetFile(fileName) { async function onGetFile(id) {
const fileHandle = await getFileHandle(fileName); const fileHandle = await getFileHandle(id);
let file = await fileHandle.getFile(); let file = await fileHandle.getFile();
self.postMessage({type: "file", file: file}); self.postMessage({type: "file", file: file});
} }
async function onDeleteFile(fileName) { async function onDeleteFile(id) {
const accessHandle = await getAccessHandle(fileName); const accessHandle = await getAccessHandle(id);
// Truncate the file to 0 bytes // Truncate the file to 0 bytes
accessHandle.truncate(0); accessHandle.truncate(0);
@ -75,5 +109,23 @@ async function onDeleteFile(fileName) {
// Always close FileSystemSyncAccessHandle if done. // Always close FileSystemSyncAccessHandle if done.
accessHandle.close(); accessHandle.close();
self.postMessage({type: "file-deleted"}); self.postMessage({type: "file-deleted", id: id});
}
async function onClearDirectory() {
const dirHandle = await navigator.storage.getDirectory();
// Iterate through directory entries and truncate all entries to 0
for await (const [id, fileHandle] of dirHandle.entries()) {
const accessHandle = await fileHandle.createSyncAccessHandle();
// Truncate the file to 0 bytes
accessHandle.truncate(0);
// Persist changes to disk.
accessHandle.flush();
// Always close FileSystemSyncAccessHandle if done.
accessHandle.close();
}
} }

View file

@ -1060,7 +1060,7 @@ class ReceiveDialog extends Dialog {
: Localization.getTranslation("dialogs.file-other-description-file-plural", null, {count: files.length - 1}); : Localization.getTranslation("dialogs.file-other-description-file-plural", null, {count: files.length - 1});
} }
const fileName = files[0].name; const fileName = files[0].displayName;
const fileNameSplit = fileName.split('.'); const fileNameSplit = fileName.split('.');
const fileExtension = '.' + fileNameSplit[fileNameSplit.length - 1]; const fileExtension = '.' + fileNameSplit[fileNameSplit.length - 1];
const fileStem = fileName.substring(0, fileName.length - fileExtension.length); const fileStem = fileName.substring(0, fileName.length - fileExtension.length);
@ -1331,7 +1331,7 @@ class ReceiveFileDialog extends ReceiveDialog {
Events.fire('notify-user', downloadSuccessfulTranslation); Events.fire('notify-user', downloadSuccessfulTranslation);
this.downloadSuccessful = true; this.downloadSuccessful = true;
this.hide() this.hide();
}; };
} }
@ -1355,7 +1355,7 @@ class ReceiveFileDialog extends ReceiveDialog {
_downloadFiles(files) { _downloadFiles(files) {
let tmpBtn = document.createElement("a"); let tmpBtn = document.createElement("a");
for (let i = 0; i < files.length; i++) { for (let i = 0; i < files.length; i++) {
tmpBtn.download = files[i].name; tmpBtn.download = files[i].displayName;
tmpBtn.href = URL.createObjectURL(files[i]); tmpBtn.href = URL.createObjectURL(files[i]);
tmpBtn.click(); tmpBtn.click();
} }
@ -1435,6 +1435,7 @@ class ReceiveFileDialog extends ReceiveDialog {
hide() { hide() {
super.hide(); super.hide();
setTimeout(async () => { setTimeout(async () => {
this._tidyUpButtons(); this._tidyUpButtons();
this._tidyUpPreviewBox(); this._tidyUpPreviewBox();
@ -2651,15 +2652,20 @@ class Base64Dialog extends Dialog {
this.$pasteBtn.innerText = Localization.getTranslation("dialogs.base64-processing"); this.$pasteBtn.innerText = Localization.getTranslation("dialogs.base64-processing");
} }
preparePasting(type) { preparePasting(type, useFallback = false) {
const translateType = type === 'text' const translateType = type === 'text'
? Localization.getTranslation("dialogs.base64-text") ? Localization.getTranslation("dialogs.base64-text")
: Localization.getTranslation("dialogs.base64-files"); : Localization.getTranslation("dialogs.base64-files");
if (navigator.clipboard.readText) { if (navigator.clipboard.readText && !useFallback) {
this.$pasteBtn.innerText = Localization.getTranslation("dialogs.base64-tap-to-paste", null, {type: translateType}); this.$pasteBtn.innerText = Localization.getTranslation("dialogs.base64-tap-to-paste", null, {type: translateType});
this._clickCallback = _ => this.processClipboard(type); this._clickCallback = _ => this.processClipboard(type);
this.$pasteBtn.addEventListener('click', _ => this._clickCallback()); this.$pasteBtn.addEventListener('click', _ => {
this._clickCallback()
.catch(_ => {
this.preparePasting(type, true);
})
});
} }
else { else {
Logger.log("`navigator.clipboard.readText()` is not available on your browser.\nOn Firefox you can set `dom.events.asyncClipboard.readText` to true under `about:config` for convenience.") Logger.log("`navigator.clipboard.readText()` is not available on your browser.\nOn Firefox you can set `dom.events.asyncClipboard.readText` to true under `about:config` for convenience.")

View file

@ -620,3 +620,26 @@ function isUrlValid(url) {
return false; return false;
} }
} }
// polyfill for crypto.randomUUID()
// Credits: @Briguy37 - https://stackoverflow.com/a/8809472/14678591
function generateUUID() {
return crypto && crypto.randomUUID()
? crypto.randomUUID()
: () => {
let
d = new Date().getTime(),
d2 = ((typeof performance !== 'undefined') && performance.now && (performance.now() * 1000)) || 0;
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
let r = Math.random() * 16;
if (d > 0) {
r = (d + r) % 16 | 0;
d = Math.floor(d / 16);
} else {
r = (d2 + r) % 16 | 0;
d2 = Math.floor(d2 / 16);
}
return (c == 'x' ? r : (r & 0x7 | 0x8)).toString(16);
});
};
}