fix: event-driven poll loop for END ACK to work around Android BLE notification delivery issue on Qualcomm chipsets
- Race ackPromise against a polling loop that yields to the event loop every 200ms (vs a single blocking await that can miss notifications) - bump SW cache v5→v6
This commit is contained in:
parent
e4ec3f1ded
commit
9dda8fe3e6
|
|
@ -0,0 +1,491 @@
|
|||
import {
|
||||
BLE_SERVICE_UUID,
|
||||
BLE_CHAR_FLASHING_UUID,
|
||||
BLE_CHAR_SERIAL_UUID,
|
||||
CMD_INIT, CMD_DATA, CMD_END, CMD_ACK, CMD_ERR,
|
||||
CHUNK_SIZE, BLE_TIMEOUT_MS, END_TIMEOUT_MS, END_ACK_INDEX, MAX_RETRIES,
|
||||
type DeployProgress, type BLEACKResponse
|
||||
} from '$types/deployer';
|
||||
|
||||
let _ackNotificationCount = 0;
|
||||
function logAck(tag: string, msg: string) {
|
||||
console.log(`[BLE-ACK#${++_ackNotificationCount}] ${tag}: ${msg}`);
|
||||
}
|
||||
|
||||
export class BLEHardwareDeployer {
|
||||
private server: BluetoothRemoteGATTServer | null = null;
|
||||
private service: BluetoothRemoteGATTService | null = null;
|
||||
private flashingChar: BluetoothRemoteGATTCharacteristic | null = null;
|
||||
private serialChar: BluetoothRemoteGATTCharacteristic | null = null;
|
||||
private device: BluetoothDevice | null = null;
|
||||
|
||||
private onSerialData: ((text: string) => void) | null = null;
|
||||
|
||||
/** Map of expected ACK index → resolver pair. Supports concurrent waits. */
|
||||
private ackResolvers = new Map<number, {
|
||||
resolve: (value: BLEACKResponse) => void;
|
||||
reject: (reason: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}>();
|
||||
|
||||
/** ACKs that arrived before waitForAck was called, keyed by index. */
|
||||
private pendingAcks = new Map<number, BLEACKResponse>();
|
||||
|
||||
private _onDisconnected: (() => void) | null = null;
|
||||
|
||||
/** Bound handler refs so we can removeEventListener on cleanup/re-pair. */
|
||||
private _onDeviceDisconnect: (() => void) | null = null;
|
||||
private _onFlashingNotification: ((event: Event) => void) | null = null;
|
||||
private _onSerialNotification: ((event: Event) => void) | null = null;
|
||||
|
||||
checkSupport(): boolean {
|
||||
return typeof navigator !== 'undefined' && 'bluetooth' in navigator;
|
||||
}
|
||||
|
||||
get isConnected(): boolean {
|
||||
return this.server?.connected ?? false;
|
||||
}
|
||||
|
||||
private withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_, reject) => {
|
||||
setTimeout(() => reject(new Error(`Timeout: ${label} (${ms}ms)`)), ms);
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
async pair(): Promise<void> {
|
||||
if (!this.checkSupport()) {
|
||||
throw new Error('Web Bluetooth tidak didukung di browser ini. Gunakan Chrome Android.');
|
||||
}
|
||||
|
||||
// Clean up any previous session's listeners before re-pairing
|
||||
this.cleanup();
|
||||
|
||||
console.log('[BLE] step 1: requestDevice...');
|
||||
this.device = await navigator.bluetooth.requestDevice({
|
||||
filters: [{ namePrefix: 'Velxio' }],
|
||||
optionalServices: [BLE_SERVICE_UUID]
|
||||
});
|
||||
console.log('[BLE] step 1: device selected:', this.device.name);
|
||||
|
||||
this._onDeviceDisconnect = () => {
|
||||
console.log('[BLE] gattserverdisconnected event');
|
||||
this._onDisconnected?.();
|
||||
this.cleanup();
|
||||
};
|
||||
this.device.addEventListener('gattserverdisconnected', this._onDeviceDisconnect);
|
||||
|
||||
console.log('[BLE] step 2: gatt.connect...');
|
||||
this.server = await this.withTimeout(
|
||||
this.device.gatt!.connect(),
|
||||
10000,
|
||||
'gatt.connect'
|
||||
);
|
||||
console.log('[BLE] step 2: connected, mtu=' + this.server.mtu);
|
||||
|
||||
console.log('[BLE] step 3: getPrimaryService...');
|
||||
this.service = await this.withTimeout(
|
||||
this.server.getPrimaryService(BLE_SERVICE_UUID),
|
||||
5000,
|
||||
'getPrimaryService'
|
||||
);
|
||||
console.log('[BLE] step 3: service found');
|
||||
|
||||
console.log('[BLE] step 4: getCharacteristic flashing...');
|
||||
this.flashingChar = await this.withTimeout(
|
||||
this.service.getCharacteristic(BLE_CHAR_FLASHING_UUID),
|
||||
5000,
|
||||
'getCharacteristic(flashing)'
|
||||
);
|
||||
console.log('[BLE] step 4: flashing char found');
|
||||
|
||||
console.log('[BLE] step 5: getCharacteristic serial...');
|
||||
this.serialChar = await this.withTimeout(
|
||||
this.service.getCharacteristic(BLE_CHAR_SERIAL_UUID),
|
||||
5000,
|
||||
'getCharacteristic(serial)'
|
||||
);
|
||||
console.log('[BLE] step 5: serial char found');
|
||||
|
||||
console.log('[BLE] step 6: requestMTU(255)...');
|
||||
try {
|
||||
await this.withTimeout(
|
||||
this.server.requestMTU(255),
|
||||
3000,
|
||||
'requestMTU'
|
||||
);
|
||||
console.log('[BLE] step 6: MTU negotiated, now ' + this.server.mtu);
|
||||
} catch (e) {
|
||||
console.log('[BLE] step 6: MTU fallback, mtu=' + this.server.mtu, e);
|
||||
}
|
||||
|
||||
console.log('[BLE] step 7: startNotifications...');
|
||||
await this.withTimeout(
|
||||
this.flashingChar.startNotifications(),
|
||||
5000,
|
||||
'startNotifications'
|
||||
);
|
||||
console.log('[BLE] step 7: notifications subscribed');
|
||||
|
||||
this._onFlashingNotification = (event: Event) => {
|
||||
const target = event.target as BluetoothRemoteGATTCharacteristic;
|
||||
const dv = target.value!;
|
||||
const value = new Uint8Array(dv.buffer, dv.byteOffset, dv.byteLength);
|
||||
this.handleFlashingNotification(value);
|
||||
};
|
||||
this.flashingChar.addEventListener('characteristicvaluechanged', this._onFlashingNotification);
|
||||
console.log('[BLE] pair() completed successfully');
|
||||
}
|
||||
|
||||
private handleFlashingNotification(value: Uint8Array) {
|
||||
const cmd = value[0];
|
||||
const index = value[1] | (value[2] << 8);
|
||||
const cmdName = cmd === CMD_ACK ? 'ACK' : cmd === CMD_ERR ? 'ERR' : 'UNKNOWN';
|
||||
logAck(cmdName, `index=${index} len=${value.length}`);
|
||||
|
||||
if (cmd === CMD_ACK) {
|
||||
const ack: BLEACKResponse = { command: cmd, index, status: 'OK' };
|
||||
|
||||
// Check if someone is waiting for this exact index
|
||||
const pending = this.ackResolvers.get(index);
|
||||
if (pending) {
|
||||
logAck(cmdName, `Found resolver for index=${index} — resolving`);
|
||||
this.ackResolvers.delete(index);
|
||||
clearTimeout(pending.timer);
|
||||
pending.resolve(ack);
|
||||
} else if (this.ackResolvers.has(0xFFFF) && index !== 0xFFFF) {
|
||||
// No one waiting for this specific index right now,
|
||||
// but someone IS waiting for END (0xFFFF) — don't
|
||||
// let a stale chunk ACK resolve it.
|
||||
logAck(cmdName, `index=${index} no resolver, END waitForAck(0xFFFF) active — dropping stale ACK`);
|
||||
} else {
|
||||
// Cache for a future waitForAck call
|
||||
logAck(cmdName, `index=${index} no resolver — caching as pendingAck`);
|
||||
this.pendingAcks.set(index, ack);
|
||||
}
|
||||
} else if (cmd === CMD_ERR) {
|
||||
const msgLen = value[3];
|
||||
const msg = msgLen > 0
|
||||
? new TextDecoder().decode(value.slice(4, 4 + msgLen))
|
||||
: 'Unknown error';
|
||||
logAck(cmdName, `index=${index} ERR: ${msg}`);
|
||||
|
||||
const pending = this.ackResolvers.get(index);
|
||||
if (pending) {
|
||||
logAck(cmdName, `Found rejecter for index=${index} — rejecting`);
|
||||
this.ackResolvers.delete(index);
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(new Error(`ERR: ${msg}`));
|
||||
} else {
|
||||
logAck(cmdName, `index=${index} no rejecter — ERR lost: ${msg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async deployHex(hexContent: string, onProgress: (p: DeployProgress) => void): Promise<void> {
|
||||
if (!this.flashingChar) throw new Error('Belum terhubung ke perangkat');
|
||||
|
||||
const binaryData = this.hexToBinary(hexContent);
|
||||
const totalChunks = Math.ceil(binaryData.length / CHUNK_SIZE);
|
||||
const totalCRC = this.crc32(binaryData);
|
||||
|
||||
console.log(`[BLE] deployHex: ${binaryData.length} bytes, ${totalChunks} chunks, CRC=0x${totalCRC.toString(16)}`);
|
||||
|
||||
onProgress({ state: 'transferring', message: 'Mengirim INIT...', totalChunks, completedChunks: 0 });
|
||||
console.log('[BLE] D: sending INIT...');
|
||||
await this.sendCommand(CMD_INIT, 0, this.uint32ToBytes(totalCRC));
|
||||
console.log('[BLE] D: INIT done');
|
||||
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
const start = i * CHUNK_SIZE;
|
||||
const end = Math.min(start + CHUNK_SIZE, binaryData.length);
|
||||
const chunk = binaryData.slice(start, end);
|
||||
const chunkCRC = this.crc32(chunk);
|
||||
|
||||
await this.sendCommandWithRetry(CMD_DATA, i, chunk, chunkCRC);
|
||||
|
||||
onProgress({
|
||||
state: 'transferring',
|
||||
message: `Mengirim chunk ${i + 1}/${totalChunks}`,
|
||||
totalChunks,
|
||||
completedChunks: i + 1
|
||||
});
|
||||
}
|
||||
console.log('[BLE] D: all DATA chunks done');
|
||||
|
||||
onProgress({ state: 'flashing', message: 'Verifikasi & flashing...', totalChunks, completedChunks: totalChunks });
|
||||
console.log('[BLE] D: END starting (30s timeout)...');
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
await this.sendCommand(CMD_END, END_ACK_INDEX, this.uint32ToBytes(totalCRC), END_TIMEOUT_MS);
|
||||
const elapsed = Date.now() - startTime;
|
||||
console.log(`[BLE] D: END ACK received after ${elapsed}ms, flash success!`);
|
||||
} catch (err) {
|
||||
const elapsed = Date.now() - startTime;
|
||||
console.log(`[BLE] D: END FAILED after ${elapsed}ms:`, err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
onProgress({ state: 'serial_bridge', message: 'Flash berhasil! Membuka Serial Monitor...', totalChunks, completedChunks: totalChunks });
|
||||
}
|
||||
|
||||
private async sendCommand(cmd: number, index: number, data: Uint8Array, timeoutMs?: number): Promise<void> {
|
||||
const cmdName = cmd === CMD_INIT ? 'INIT' : cmd === CMD_DATA ? 'DATA' : cmd === CMD_END ? 'END' : `0x${cmd.toString(16)}`;
|
||||
const payload = new Uint8Array(4 + data.length + 4);
|
||||
payload[0] = cmd;
|
||||
payload[1] = index & 0xFF;
|
||||
payload[2] = (index >> 8) & 0xFF;
|
||||
payload[3] = data.length;
|
||||
payload.set(data, 4);
|
||||
|
||||
const crc = this.crc32(data);
|
||||
payload.set([
|
||||
crc & 0xFF, (crc >> 8) & 0xFF, (crc >> 16) & 0xFF, (crc >> 24) & 0xFF
|
||||
], 4 + data.length);
|
||||
|
||||
const ackTimeout = timeoutMs ?? BLE_TIMEOUT_MS;
|
||||
console.log(`[BLE-CMD] ${cmdName} idx=${index}: waitForAck timeout=${ackTimeout}ms start`);
|
||||
const ackPromise = this.waitForAck(index, ackTimeout);
|
||||
try {
|
||||
console.log(`[BLE-CMD] ${cmdName} idx=${index}: writing payload (${payload.length} bytes)...`);
|
||||
await this.withTimeout(
|
||||
this.flashingChar!.writeValueWithResponse(payload),
|
||||
3000,
|
||||
'writeValueWithResponse'
|
||||
);
|
||||
console.log(`[BLE-CMD] ${cmdName} idx=${index}: write done, now waiting for ACK...`);
|
||||
} catch (e) {
|
||||
console.log(`[BLE-CMD] ${cmdName} idx=${index}: write failed, still waiting for ACK`, e);
|
||||
}
|
||||
|
||||
/* END (flash ~8s): Android BLE stack sometimes fails to deliver the
|
||||
* notification through the normal characteristicvaluechanged handler
|
||||
* while a writeValueWithResponse await is still in progress on some
|
||||
* Qualcomm chipsets. Race the ackPromise against a poll loop that
|
||||
* yields to the event loop every 200ms and checks pendingAcks (which
|
||||
* the notification handler fills if the resolver path was missed). */
|
||||
if (cmd === CMD_END) {
|
||||
const deadline = Date.now() + ackTimeout;
|
||||
const pollPromise: Promise<void> = new Promise(resolve => {
|
||||
(async () => {
|
||||
while (Date.now() < deadline) {
|
||||
if (this.pendingAcks.has(index)) {
|
||||
this.pendingAcks.delete(index);
|
||||
const entry = this.ackResolvers.get(index);
|
||||
if (entry) {
|
||||
clearTimeout(entry.timer);
|
||||
this.ackResolvers.delete(index);
|
||||
}
|
||||
console.log(`[BLE-CMD] END idx=${index}: ACK received via poll (pendingAcks)`);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
await new Promise<void>(r => setTimeout(r, 200));
|
||||
}
|
||||
console.log(`[BLE-CMD] END idx=${index}: poll deadline passed — ackPromise must win`);
|
||||
resolve(); // resolve poll so the race falls through to ackPromise
|
||||
})();
|
||||
});
|
||||
await Promise.race([ackPromise, pollPromise]);
|
||||
console.log(`[BLE-CMD] END idx=${index}: ackPromise or poll resolved`);
|
||||
return;
|
||||
}
|
||||
|
||||
await ackPromise;
|
||||
console.log(`[BLE-CMD] ${cmdName} idx=${index}: ackPromise resolved OK`);
|
||||
}
|
||||
|
||||
private async sendCommandWithRetry(cmd: number, index: number, data: Uint8Array, chunkCRC: number): Promise<void> {
|
||||
const payload = new Uint8Array(4 + data.length + 4);
|
||||
payload[0] = cmd;
|
||||
payload[1] = index & 0xFF;
|
||||
payload[2] = (index >> 8) & 0xFF;
|
||||
payload[3] = data.length;
|
||||
payload.set(data, 4);
|
||||
payload.set([
|
||||
chunkCRC & 0xFF, (chunkCRC >> 8) & 0xFF, (chunkCRC >> 16) & 0xFF, (chunkCRC >> 24) & 0xFF
|
||||
], 4 + data.length);
|
||||
|
||||
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const ackPromise = this.waitForAck(index);
|
||||
await this.flashingChar!.writeValueWithResponse(payload);
|
||||
await ackPromise;
|
||||
return;
|
||||
} catch (err) {
|
||||
if (attempt === MAX_RETRIES - 1) throw err;
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private waitForAck(expectedIndex: number, timeoutMs: number = BLE_TIMEOUT_MS): Promise<BLEACKResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Check for a cached ACK that arrived before we started waiting
|
||||
const cached = this.pendingAcks.get(expectedIndex);
|
||||
if (cached) {
|
||||
this.pendingAcks.delete(expectedIndex);
|
||||
logAck('WAIT', `pendingAcks hit for index=${expectedIndex} actual=${cached.index} — resolving immediately`);
|
||||
resolve(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
logAck('WAIT', `Registering resolver for index=${expectedIndex} timeout=${timeoutMs}ms`);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
const entry = this.ackResolvers.get(expectedIndex);
|
||||
if (entry) {
|
||||
this.ackResolvers.delete(expectedIndex);
|
||||
logAck('WAIT', `TIMEOUT after ${timeoutMs}ms for index=${expectedIndex} — rejecting`);
|
||||
reject(new Error(`Timeout menunggu ACK untuk chunk ${expectedIndex}`));
|
||||
} else {
|
||||
logAck('WAIT', `Timeout fired but resolver already consumed for index=${expectedIndex} — ignoring`);
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
this.ackResolvers.set(expectedIndex, { resolve, reject, timer });
|
||||
});
|
||||
}
|
||||
|
||||
async startSerialMonitor(onData: (text: string) => void): Promise<void> {
|
||||
if (!this.serialChar) throw new Error('Belum terhubung ke perangkat');
|
||||
|
||||
this.onSerialData = onData;
|
||||
|
||||
await this.serialChar.startNotifications();
|
||||
this._onSerialNotification = (event: Event) => {
|
||||
const target = event.target as BluetoothRemoteGATTCharacteristic;
|
||||
const dv = target.value!;
|
||||
const value = new Uint8Array(dv.buffer, dv.byteOffset, dv.byteLength);
|
||||
const text = new TextDecoder().decode(value);
|
||||
this.onSerialData?.(text);
|
||||
};
|
||||
this.serialChar.addEventListener('characteristicvaluechanged', this._onSerialNotification);
|
||||
}
|
||||
|
||||
async sendSerialInput(text: string): Promise<void> {
|
||||
if (!this.serialChar) throw new Error('Belum terhubung ke perangkat');
|
||||
const encoder = new TextEncoder();
|
||||
await this.serialChar.writeValueWithoutResponse(encoder.encode(text));
|
||||
}
|
||||
|
||||
stopSerialMonitor(): void {
|
||||
if (this.serialChar && this._onSerialNotification) {
|
||||
this.serialChar.removeEventListener('characteristicvaluechanged', this._onSerialNotification);
|
||||
this.serialChar.stopNotifications();
|
||||
}
|
||||
this._onSerialNotification = null;
|
||||
this.onSerialData = null;
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.stopSerialMonitor();
|
||||
if (this.flashingChar) {
|
||||
this.flashingChar.stopNotifications();
|
||||
}
|
||||
this.server?.disconnect();
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
onDisconnected(callback: () => void): void {
|
||||
this._onDisconnected = callback;
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
// Remove device-level event listeners
|
||||
if (this.device && this._onDeviceDisconnect) {
|
||||
this.device.removeEventListener('gattserverdisconnected', this._onDeviceDisconnect);
|
||||
}
|
||||
this._onDeviceDisconnect = null;
|
||||
|
||||
// Remove flashing notification listener
|
||||
if (this.flashingChar && this._onFlashingNotification) {
|
||||
this.flashingChar.removeEventListener('characteristicvaluechanged', this._onFlashingNotification);
|
||||
}
|
||||
this._onFlashingNotification = null;
|
||||
|
||||
// Remove serial notification listener
|
||||
if (this.serialChar && this._onSerialNotification) {
|
||||
this.serialChar.removeEventListener('characteristicvaluechanged', this._onSerialNotification);
|
||||
}
|
||||
this._onSerialNotification = null;
|
||||
|
||||
this.server = null;
|
||||
this.service = null;
|
||||
this.flashingChar = null;
|
||||
this.serialChar = null;
|
||||
this.device = null;
|
||||
// Clear all pending ACK resolvers
|
||||
for (const [idx, entry] of this.ackResolvers) {
|
||||
clearTimeout(entry.timer);
|
||||
}
|
||||
this.ackResolvers.clear();
|
||||
this.pendingAcks.clear();
|
||||
this.onSerialData = null;
|
||||
}
|
||||
|
||||
private hexToBinary(hexContent: string): Uint8Array {
|
||||
const bytes: number[] = [];
|
||||
let extendedAddress = 0;
|
||||
|
||||
for (const line of hexContent.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed[0] !== ':') continue;
|
||||
|
||||
const byteCount = parseInt(trimmed.substring(1, 3), 16);
|
||||
const address = parseInt(trimmed.substring(3, 7), 16) + extendedAddress;
|
||||
const recordType = parseInt(trimmed.substring(7, 9), 16);
|
||||
|
||||
if (recordType === 0x00) {
|
||||
for (let i = 0; i < byteCount; i++) {
|
||||
const byteOffset = 9 + i * 2;
|
||||
if (byteOffset + 2 <= trimmed.length) {
|
||||
const byteVal = parseInt(trimmed.substring(byteOffset, byteOffset + 2), 16);
|
||||
bytes[address + i] = byteVal;
|
||||
}
|
||||
}
|
||||
} else if (recordType === 0x04) {
|
||||
extendedAddress = parseInt(trimmed.substring(9, 13), 16) << 16;
|
||||
} else if (recordType === 0x01) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const result = new Uint8Array(bytes.length);
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
result[i] = bytes[i] ?? 0;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private crc32Table: Uint32Array | null = null;
|
||||
|
||||
private crc32(data: Uint8Array): number {
|
||||
if (!this.crc32Table) {
|
||||
this.crc32Table = new Uint32Array(256);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let c = i;
|
||||
for (let j = 0; j < 8; j++) {
|
||||
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
|
||||
}
|
||||
this.crc32Table[i] = c;
|
||||
}
|
||||
}
|
||||
let crc = 0xFFFFFFFF;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
crc = this.crc32Table[(crc ^ data[i]) & 0xFF] ^ (crc >>> 8);
|
||||
}
|
||||
return (crc ^ 0xFFFFFFFF) >>> 0;
|
||||
}
|
||||
|
||||
private uint32ToBytes(value: number): Uint8Array {
|
||||
return new Uint8Array([
|
||||
value & 0xFF,
|
||||
(value >> 8) & 0xFF,
|
||||
(value >> 16) & 0xFF,
|
||||
(value >> 24) & 0xFF
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
// static/sw.js
|
||||
const CACHE_VERSION = 'elemes-v1';
|
||||
const CACHE_VERSION = 'elemes-v6';
|
||||
const STATIC_CACHE = `${CACHE_VERSION}-static`;
|
||||
const API_CACHE = `${CACHE_VERSION}-api`;
|
||||
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
||||
|
|
|
|||
Loading…
Reference in New Issue